diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index df04364e44..b6f68e5ccc 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -200,76 +200,6 @@ var ( }, } - shadowMode string - shadowTargetLocal bool - shadowUsePgDelta bool - shadowSchema []string - shadowProjectRef string - - // dbShadowCmd is a hidden seam used by the native-TypeScript db diff/pull - // commands to provision the throwaway shadow database that the diff "source" - // runs against, then leave it running so the TS caller can run the differ - // (migra or pg-delta) itself and remove the container afterwards. It prints - // three newline-separated lines to stdout: the container id, the source - // Postgres URL, and an optional target-override URL (empty unless the - // local-target declarative branch redirects the diff target to a second - // shadow database). The URLs are emitted WITHOUT the password - // (ToPostgresURLWithoutPassword) so we never log a credential to stdout - // (CWE-312); the TS caller re-injects the local Postgres password it already - // resolves from config.toml, which is the same value the shadow uses. Shadow - // provisioning (start.SetupDatabase) is not yet ported, which is why this - // stays in Go. - dbShadowCmd = &cobra.Command{ - Use: "__shadow", - Hidden: true, - Short: "Internal: provision a shadow database for the native db diff/pull commands", - RunE: func(cmd *cobra.Command, args []string) error { - // The hidden __shadow command carries none of the db-url/local/linked - // target flags, so the root PersistentPreRunE's ParseDatabaseConfig - // never loads supabase/config.toml (it only loads when a target flag - // is set, internal/utils/flags/db_url.go:46-90). Load it explicitly so - // the shadow is provisioned from the project's [db] settings — shadow - // port, Postgres version, service baseline, and especially the - // password: the native-TS caller injects the config.toml password into - // the seam URLs, so the shadow must be created with that same password. - fsys := afero.NewOsFs() - // On the linked path the native-TS caller passes the resolved project - // ref via --project-ref so the shadow is built from the same - // remote-merged config the Go monolith uses: LoadConfig seeds - // utils.Config.ProjectId from flags.ProjectRef and merges the matching - // [remotes.] block (pkg/config/config.go). Omitted on local/db-url - // shadows, which the monolith never remote-merges, so the base config is - // used exactly as before. - if len(shadowProjectRef) > 0 { - flags.ProjectRef = shadowProjectRef - } - if err := flags.LoadConfig(fsys); err != nil { - return err - } - var src diff.ShadowSource - var err error - switch shadowMode { - case "declarative": - src, err = diff.PrepareRawShadow(cmd.Context()) - case "diff", "": - src, err = diff.PrepareShadowSource(cmd.Context(), shadowSchema, shadowTargetLocal, shadowUsePgDelta, fsys) - default: - return fmt.Errorf("unknown shadow mode: %s", shadowMode) - } - if err != nil { - return err - } - fmt.Println(src.Container) - fmt.Println(utils.ToPostgresURLWithoutPassword(src.Source)) - if src.TargetOverride != nil { - fmt.Println(utils.ToPostgresURLWithoutPassword(*src.TargetOverride)) - } else { - fmt.Println("") - } - return nil - }, - } - dbRemoteCmd = &cobra.Command{ Hidden: true, Use: "remote", @@ -612,14 +542,6 @@ func init() { pullFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.") cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", pullFlags.Lookup("password"))) dbCmd.AddCommand(dbPullCmd) - // Build hidden shadow-provisioning seam command - shadowFlags := dbShadowCmd.Flags() - shadowFlags.StringVar(&shadowMode, "mode", "diff", "Shadow mode: diff (baseline + migrations) or declarative (bare shadow).") - shadowFlags.BoolVar(&shadowTargetLocal, "target-local", false, "Whether the diff target is the local database (enables the declarative-schema branch).") - shadowFlags.BoolVar(&shadowUsePgDelta, "use-pg-delta", false, "Whether pg-delta is the active diff engine (selects the declarative-apply path).") - shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") - shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") - dbCmd.AddCommand(dbShadowCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") diff --git a/apps/cli-go/cmd/start.go b/apps/cli-go/cmd/start.go index 1431d5b0aa..6d9c557e61 100644 --- a/apps/cli-go/cmd/start.go +++ b/apps/cli-go/cmd/start.go @@ -5,7 +5,9 @@ package cmd // talks to Docker directly for `start` and never delegates to this binary // for it, and no other still-live TS->Go delegation seam (db test, db // branch/remote, db diff --use-pgadmin/--use-pg-schema, db pull -// --experimental, the hidden db __shadow/__catalog seams -- the sibling +// --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 -- see diff --git a/apps/cli-go/internal/utils/connect.go b/apps/cli-go/internal/utils/connect.go index 406e515370..6dad6c5c4a 100644 --- a/apps/cli-go/internal/utils/connect.go +++ b/apps/cli-go/internal/utils/connect.go @@ -26,17 +26,6 @@ func ToPostgresURL(config pgconn.Config) string { return toPostgresURL(config, url.UserPassword(config.User, config.Password)) } -// ToPostgresURLWithoutPassword renders the connection URL exactly like -// ToPostgresURL but omits the password from the userinfo. Use it for callers that -// print the URL to stdout (the hidden `db __shadow` seam): embedding the password -// there is clear-text logging of a credential (CWE-312, flagged by CodeQL). The -// password is never the seam's to share — the TS caller that consumes the seam -// output re-injects the local Postgres password it already resolves from -// config.toml (`utils.Config.Db.Password`). -func ToPostgresURLWithoutPassword(config pgconn.Config) string { - return toPostgresURL(config, url.User(config.User)) -} - func toPostgresURL(config pgconn.Config, userinfo *url.Userinfo) string { timeoutSecond := int64(config.ConnectTimeout.Seconds()) if timeoutSecond == 0 { diff --git a/apps/cli-go/internal/utils/connect_test.go b/apps/cli-go/internal/utils/connect_test.go index 80684df8d1..876d7ea7c7 100644 --- a/apps/cli-go/internal/utils/connect_test.go +++ b/apps/cli-go/internal/utils/connect_test.go @@ -398,23 +398,6 @@ func TestPostgresURL(t *testing.T) { assert.Equal(t, `postgresql://postgres:%21%40%23$%25%5E&%2A%28%29@[2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432/?connect_timeout=10&options=test`, url) } -func TestPostgresURLWithoutPassword(t *testing.T) { - config := pgconn.Config{ - Host: "2406:da18:4fd:9b0d:80ec:9812:3e65:450b", - Port: 5432, - User: "postgres", - Password: "!@#$%^&*()", - RuntimeParams: map[string]string{ - "options": "test", - }, - } - url := ToPostgresURLWithoutPassword(config) - // Same as ToPostgresURL but with the password omitted from the userinfo, so a - // credential is never written to stdout by the db __shadow seam. - assert.Equal(t, `postgresql://postgres@[2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432/?connect_timeout=10&options=test`, url) - assert.NotContains(t, url, "%21%40%23") -} - func TestPreserveTLSConfig(t *testing.T) { const dsn = "postgresql://postgres:pw@example.com:5432/postgres" diff --git a/apps/cli/docs/binary-distribution.md b/apps/cli/docs/binary-distribution.md index 3eef8bcb85..9a5e9bf098 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 __shadow`/`__catalog` seams — 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-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`. ## See Also diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 6b2470e42b..fdf0377abc 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 Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. `--use-pg-schema` is deprecated (CLI-1960: TS-only stderr warning + `--help` note) in favor of the pg-delta engine or the default migra engine — it wraps the in-process `stripe/pg-schema-diff` Go library, which has no TS/container equivalent, so it is a documented keep-in-Go exception, not a pending port. It will be the sole remaining Go delegation once `--use-pgadmin`, the `db __shadow`/`db __db-bootstrap` seams, and the other in-flight M9 issues are done. | +| `db 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 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. | 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 814e5c4a47..0761bb373c 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -7,14 +7,14 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T ## Files Read -| Path | Format | When | -| -------------------------------------------------- | ---------- | ----------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | -| `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) | -| `/supabase/database/**` (declarative dir) | SQL | local target when declarative schemas exist | -| `~/.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/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) | +| `[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) | ## Files Written @@ -28,12 +28,15 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T ## Docker -- Edge-runtime container (pg-delta / migra diff scripts; also runs the pg-delta +- Edge-runtime container (pg-delta / migra diff scripts; also the declarative + pg-delta apply script for the local-target branch, and runs the pg-delta catalog-export script for explicit `--from/--to migrations` on a cache miss — CLI-1959, native, no longer the hidden Go `__catalog` seam). -- Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam; - explicit `--from/--to migrations` reuses this same seam call — `mode: "diff"` — - on a cache miss, rather than a second, `__catalog`-specific shadow). +- Shadow Postgres container — provisioned and torn down natively (`legacyPrepareShadowSource` + in `legacy/commands/db/shared/legacy-shadow-source.ts`, over the lower-level primitives in + `legacy/shared/db-bootstrap/shadow-database.ts`), no longer via a Go seam. Explicit + `--from/--to migrations` reuses this same native call (`mode: "diff"`) on a cache + miss, rather than a second, `__catalog`-specific shadow. - `supabase/migra` container — the migra OOM bash fallback only. ## API Routes (linked path, via the db-config resolver) @@ -88,9 +91,9 @@ Progress strings still go to stderr; stdout carries a single structured envelope - 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`, - shared with `db push`'s post-apply cache write), and on a miss, the existing - `db __shadow --mode diff` seam call (unchanged — still Go, out of scope for - CLI-1959) plus a native pg-delta catalog export. No hidden Go + shared with `db push`'s post-apply cache write), and on a miss, a natively-provisioned + shadow database (CLI-1956 — `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`, + 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-pg-schema` is deprecated (CLI-1960) — keep-in-Go exception @@ -110,9 +113,9 @@ 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, the `db __shadow` seam (the sibling `db -__db-bootstrap` seam was already removed outright by CLI-1955), and the rest of -the M9 milestone's in-flight issues are done — it is not there yet. +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). Given that, the flag is now deprecated rather than ported: 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 47689ac6d4..df3c4942ee 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -1,10 +1,17 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; -import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyNetworkIdFlag, +} from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +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 { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; @@ -14,6 +21,15 @@ import { legacyMakeDir } from "../../../shared/legacy-make-dir.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 { + legacyCreateShadowDatabase, + legacyRemoveShadowDatabase, +} from "../../../shared/db-bootstrap/shadow-database.ts"; +import { + legacyResolveLocalProjectId, + legacySanitizeProjectId, +} from "../../../shared/legacy-docker-ids.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { @@ -29,7 +45,10 @@ import { legacyDiffMigra } from "../shared/legacy-migra.ts"; import { legacyResolveMigrationsCatalogRef } from "../../../shared/legacy-pgdelta.cache.ts"; import { legacyWritePgDeltaMigrations } from "../shared/legacy-pgdelta-migrations.write.ts"; import { type LegacyPgDeltaContext, legacyDiffPgDelta } from "../../../shared/legacy-pgdelta.ts"; -import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; +import { + legacyPrepareShadowSource, + legacyShadowRunInputFromLocalContainerInputs, +} from "../shared/legacy-shadow-source.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyClassifyExplicitRef, legacyUnknownTargetMessage } from "./diff.explicit.ts"; import { @@ -95,7 +114,6 @@ const rebuildDelegateArgs = (flags: LegacyDbDiffFlags): Array => { export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: LegacyDbDiffFlags) { const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; - const seam = yield* LegacyDeclarativeSeam; const proxy = yield* LegacyGoProxy; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; @@ -103,6 +121,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; + const debug = yield* LegacyDebugFlag; // Resolved linked ref, captured so the post-run finalizer caches the project // (GET /v1/projects/{ref}) — Go's `ensureProjectGroupsCached` (cmd/root.go:214). @@ -231,16 +250,20 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy return legacyToPostgresURL(resolved.conn); } case "migrations": { - // Native (CLI-1959): mirrors Go's `resolveMigrationsCatalogRef` - // (`explicit.go:88-126`) exactly — see `legacyResolveMigrationsCatalogRef`'s - // doc comment. The pg-delta context is built from whatever `cfg` is - // current at this point in the cascade (possibly re-merged by an - // earlier "linked" ref above), matching Go's stateful pre-run. + // Native (CLI-1959 cache mechanics; CLI-1956 native shadow provisioning + // — see `legacyResolveMigrationsCatalogRef`'s doc comment): mirrors Go's + // `resolveMigrationsCatalogRef` (`explicit.go:88-126`) exactly. The + // pg-delta context AND the shadow's own container spec (`cfg` below, + // passed through to `legacyResolveMigrationsCatalogRef`'s `toml` + // parameter) are built from whatever `cfg` is current at this point in + // the cascade (possibly re-merged by an earlier "linked" ref above), + // matching Go's stateful pre-run. const migrationsCtx: LegacyPgDeltaContext = { projectId: Option.getOrElse(cliConfig.projectId, () => ""), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, + projectEnv: cfg.projectEnv, }; // Pass the linked ref only if one resolved earlier in the cascade, so // the shadow merges the same remote override Go's in-process @@ -250,6 +273,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy fs, path, migrationsCtx, + cfg, mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}, ); } @@ -268,6 +292,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, + projectEnv: cfg.projectEnv, }; const result = yield* legacyDiffPgDelta(explicitCtx, { sourceRef, @@ -363,30 +388,119 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy : Option.isSome(flags.linked) ? "linked" : "local"; + + // Go's `ParseDatabaseConfig` resolves the linked ref via the hard `LoadProjectRef`, THEN + // reads the `[remotes.]`-merged config (`LoadConfig`, which prints "Loading config + // override" unconditionally the moment a remote matches — `pkg/config/config.go:605`) — + // and only AFTER that calls `NewDbConfigWithPassword`, which does the actual connection + // work (TCP probe / temp-role mint over the Management API, `flags/db_url.go:87-97`). + // Pre-load the ref and read config here, before `resolver.resolve()` below, so the + // override print (and the merged-config validation) happen in that same order. + // Previously this read — and its print — ran AFTER `resolve()`, so a `resolve()` failure + // (bad password, unreachable host, network-ban lookup, …) left the user never knowing + // which `[remotes.*]` block had matched (review: PRRT_kwDOErm0O86XHvYl, pull.handler.ts's + // identical fix). The default `db diff` target is local/db-url, which never merges a + // remote block, so only the linked path pre-resolves a ref. + let linkedRef: string | undefined; + if (connType === "linked") { + const projectRefResolver = yield* LegacyProjectRefResolver; + linkedRef = yield* projectRefResolver.loadProjectRef(Option.none()); + // Cache the ref the moment it's known, not after `cfg`/`localInputs` below (both + // fallible) resolve — Go's `ensureProjectGroupsCached` (`cmd/root.go:212-233`) reads the + // GLOBAL `flags.ProjectRef` singleton `LoadProjectRef` sets as a side effect, and runs + // unconditionally after `rootCmd.ExecuteC()` regardless of whether the command itself + // errored (`cmd/root.go:169-175` never checks `err` before calling it) — so Go caches a + // resolved ref even when a LATER step (config validation, connection, the diff itself) + // fails. Setting `linkedRefForCache` here, right after the ref resolves, reproduces that + // instead of only doing so after `cfg`/`localInputs`/`resolver.resolve()` all succeed. + linkedRefForCache = linkedRef; + } + const cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef); + if (cfg.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${cfg.appliedRemote}]\n`, "stderr"); + } + + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + // Built BEFORE `resolver.resolve()` below, not just before the "Creating shadow + // database..." banner: this call performs a SECOND config load + // (`legacyLoadLocalProjectContext`'s `@supabase/config` read, distinct from `cfg` + // above) and its own validation (e.g. enabled API TLS's cert/key files, read here — + // `cfg` above only tracks their dotted keys for remote-override gating, it never reads + // the files), which can print a warning (e.g. deprecated `[inbucket]`) or fail + // outright. Go's `flags.LoadConfig` does ALL config loading (including any warnings) + // once, in the root `PersistentPreRunE`, strictly before `NewDbConfigWithPassword` — + // `resolver.resolve()`'s own parity target, see that call's doc comment above — or + // `DiffDatabase` ever prints "Creating shadow database..." (`internal/db/diff/ + // diff.go:212`) run. Previously this validation ran AFTER `resolver.resolve()` (a + // linked target's temp-role mint over the Management API), so a config broken only in + // a field this build reads surfaced after that network side effect instead of before + // it, unlike Go (review: PRRT_kwDOErm0O86XIUK1, pull.handler.ts's identical fix). Only + // the actual Docker-image resolution below (`resolvePostgresImage`, lazy until this + // point) is the provisioning work the banner itself announces. + const localInputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + cliConfig.workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + // So the shadow's own container spec (image/JWT secret/root key/db.settings/service + // enabled-for-setup flags) reflects the matching `[remotes.]` override too, same + // as `cfg` above (`legacyReadDbToml(..., linkedRef)`) — Go remote-merges the WHOLE + // config uniformly on the linked path (`LoadConfig` seeds `flags.ProjectRef` before + // every field read). + connType === "linked" ? linkedRef : undefined, + // `cfg`'s OWN remote-override-key tracking (same matched block) — so a remote-set + // bootstrap field (e.g. `db.major_version`) isn't re-overridden by a conflicting + // `SUPABASE_*` env var when deriving the shadow's container spec. + cfg.remoteOverrideKeys, + ); + const resolved = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver, password: Option.none(), }); - const linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + if (linkedRef === undefined) { + linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + } if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = legacyToPostgresURL(resolved.conn); - - // Read config with the resolved linked ref so a matching `[remotes.]` - // block merges before the engine/format/runtime are read — Go loads config - // after `LoadProjectRef` on the linked path (`flags/db_url.go:87-97`). The - // default `db diff` target is local/db-url, which never merges a remote block, - // so it reads the base config here (Go's local/direct `LoadConfig`, no ref). - const cfg = - connType === "linked" && linkedRef !== undefined - ? yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef) - : yield* legacyReadDbToml(fs, path, cliConfig.workdir); const ctx: LegacyPgDeltaContext = { - projectId: Option.getOrElse(cliConfig.projectId, () => ""), + // Go's `UpdateDockerIds` derives `EdgeRuntimeId` from the ALREADY-sanitized + // `Config.ProjectId` singleton (`internal/utils/config.go:57-76`, sanitized once by + // `Config.Validate` at config-load time) — `SUPABASE_PROJECT_ID` env override wins, + // then config.toml's `project_id`, then the workdir basename fallback + // (`pkg/config/config.go:563-570`). `cliConfig.projectId` alone is env-only, so a + // project that relies on `config.toml`'s `project_id` (or the workdir-basename + // default) previously resolved to an empty project id here, mounting the WRONG + // `supabase_edge_runtime_` Deno-cache volume — see `legacy-pgdelta.seam.layer.ts`'s + // `ensureLocalDatabaseStarted` for the same resolution already established for this + // command family (review: PRRT_kwDOErm0O86XAlIw). + // + // `cfg.appliedRemote !== undefined` suppresses that env argument entirely: `cfg.projectId` + // already reflects the matched `[remotes.]` block's own `project_id` at viper's + // override tier (`legacyReadDbToml`'s `remoteOverrideKeys.has("project_id")` gate, review: + // PRRT_kwDOErm0O86XHGDL) — but `legacyResolveLocalProjectId` tries its FIRST argument + // before its second, so passing the raw, ungated `cliConfig.projectId` here re-introduced + // exactly the bug that fix closed for `cfg.projectId` itself: an unrelated ambient + // `SUPABASE_PROJECT_ID` would still win over the matched remote's own id, mounting the + // wrong Deno-cache volume for a linked pg-delta diff. Mirrors the same suppression + // `legacy-local-project-context.ts`'s own `legacyLoadLocalProjectContext` already applies + // (review: PRRT_kwDOErm0O86XI1w8). + projectId: legacySanitizeProjectId( + legacyResolveLocalProjectId( + cfg.appliedRemote !== undefined ? undefined : Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(cfg.projectId), + cliConfig.workdir, + ), + ), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, + projectEnv: cfg.projectEnv, }; const formatOptions = Option.getOrElse(cfg.pgDelta.formatOptions, () => ""); @@ -405,47 +519,86 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }); yield* output.raw("Creating shadow database...\n", "stderr"); - const shadow = yield* seam.provisionShadow({ - mode: "diff", + const resolvedShadowImage = yield* localInputs.resolvePostgresImage; + const shadowInput = { + ...legacyShadowRunInputFromLocalContainerInputs( + localInputs, + resolvedShadowImage, + cfg, + fs, + path, + ), targetLocal: resolved.isLocal, usePgDelta: useDelta, - schema: flags.schema, - // Linked path only: the shadow merges the same `[remotes.]` override - // the engine/format read above (Go builds the shadow from the remote-merged - // config). Default `db diff` is local, which never merges a remote block. - projectRef: connType === "linked" ? linkedRef : undefined, - }); - - const diffResult = yield* Effect.gen(function* () { - const target = shadow.targetUrlOverride ?? targetUrl; - yield* output.raw( - flags.schema.length > 0 - ? `Diffing schemas: ${flags.schema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - if (useDelta) { - 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 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 }; - }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + // `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 its staged secret directory 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). + // + // `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) { + 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 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, { + containerId: handle.containerId, + secretDirId: handle.secretDirId, + workdir: cliConfig.workdir, + }), + ); const out = diffResult.sql; // Detect the branch from the resolved workdir, not the caller's CWD: Go 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 b73557e9b0..b67b6039e9 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 @@ -1,27 +1,40 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, Fiber, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { + LEGACY_FAKE_SHADOW_CONTAINER_ID, + LEGACY_VALID_REF, legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyShadowContainerCliSpawner, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, + LegacyExperimentalFlag, LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; -import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + LegacyDbConnection, + type LegacyDbSession, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; import { @@ -41,12 +54,54 @@ interface SetupOpts { // When set, the pg-delta edge mock emits a multi-unit plan envelope (one file // per entry) instead of the single-unit wrap of `diffSql`. readonly diffFiles?: ReadonlyArray<{ readonly name: string; readonly sql: string }>; - readonly targetOverride?: string; readonly oom?: boolean; // edge-runtime OOMs; the bash fallback returns `diffSql` readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run + // When set, the shadow's own PG15+ one-shot platform-baseline job(s) exit + // non-zero, exercising cleanup-on-partial-failure (the shadow is still removed). + readonly failShadowSetupJob?: boolean; readonly networkId?: string; // --network-id value forwarded to docker runs // When set, the Nth `writeFileString` fails, exercising cleanup-on-failure. readonly failWriteOnCall?: number; + // When set, the shadow container never reports healthy — for the interrupt-during- + // health-wait regression coverage (review: PRRT_kwDOErm0O86XMrID). See + // `mockLegacyShadowContainerCliSpawner`'s own doc comment for why this is required + // (not `Effect.never`) to observe a genuinely suspended retry loop. + readonly neverHealthyShadow?: boolean; + // `LegacyCliConfig.projectId` (Go's `SUPABASE_PROJECT_ID` env-only reader). Defaults to + // `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; +} + +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + +/** Records every `LegacyDbConnection.connect` target's database name, and every `exec`/`query` SQL run against it. */ +function fakeShadowDbConnection() { + const connectedDatabases: Array = []; + const execCalls: Array = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: (cfg: LegacyPgConnInput) => + Effect.sync(() => { + connectedDatabases.push(cfg.database); + const session: LegacyDbSession = { + exec: (sql) => + Effect.sync(() => { + execCalls.push(sql); + }), + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return session; + }), + }); + return { layer, connectedDatabases, execCalls }; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -54,13 +109,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const provisionCalls: Array<{ - mode: string; - targetLocal: boolean; - usePgDelta: boolean; - projectRef?: string; - }> = []; - const removedContainers: string[] = []; const exportCalls: string[] = []; const exportCatalogCalls: Array<{ mode: string; projectRef?: string }> = []; const seam = Layer.succeed(LegacyDeclarativeSeam, { @@ -71,20 +119,16 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, - provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { - provisionCalls.push({ mode, targetLocal, usePgDelta, projectRef }); - return Effect.succeed({ - container: "shadow-1", - sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: opts.targetOverride, - }); - }, - removeShadowContainer: (container) => - Effect.sync(() => { - removedContainers.push(container); - }), }); + // Shadow provisioning is native (CLI-1956): a real docker-spawner fake backs + // container create/start/health-inspect/cleanup, and a real (fake) Postgres + // session backs the shadow's own platform-baseline/migration/declarative setup. + const shadowSpawner = mockLegacyShadowContainerCliSpawner({ + neverHealthy: opts.neverHealthyShadow ?? false, + }); + const shadowDbConnection = fakeShadowDbConnection(); + const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { @@ -119,8 +163,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); - // Exercised only by the migra OOM bash fallback. + // `dockerCalls` tracks the migra OOM bash fallback's own `runCapture` calls — the + // native shadow's PG15+ one-shot setup jobs (`legacyRunStartMigrateJob`) go through + // `runStream` instead (constant-memory stdout discard, matching Go's `io.Discard` + // writer for these jobs), so they're tracked separately in `shadowSetupJobCalls` + // (their `env`, notably `DB_HOST`, is the one shadow-specific parameterization + // CLI-1956 exists to get right). const dockerCalls: unknown[] = []; + const shadowSetupJobCalls: Array<{ readonly env: Readonly> }> = []; const docker = Layer.succeed(LegacyDockerRun, { run: () => Effect.die("run unused"), runCapture: (dockerOpts) => { @@ -131,11 +181,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { stderr: "", }); }, - runStream: () => Effect.die("runStream unused"), - }); - - const dbConnection = Layer.succeed(LegacyDbConnection, { - connect: () => Effect.die("connect unused"), + // The shadow's own PG15+ one-shot platform-baseline job(s). + runStream: (dockerOpts) => { + shadowSetupJobCalls.push(dockerOpts); + return Effect.succeed({ + exitCode: opts.failShadowSetupJob === true ? 1 : 0, + stderr: "", + }); + }, }); const resolverCalls: unknown[] = []; @@ -157,6 +210,19 @@ function setup(workdir: string, opts: SetupOpts = {}) { resolvePoolerFallback: () => Effect.succeed(Option.none()), }); + // The linked ref is now pre-loaded (for the config-override print, ahead of + // `resolver.resolve()`'s own network work — review: PRRT_kwDOErm0O86XHvYl) via + // `LegacyProjectRefResolver`, mirroring the SAME ref `resolver`'s own mock embeds in + // its resolved `ref` above, so both stay consistent regardless of whether a test sets + // `opts.linkedRef` (mirrors `reset.integration.test.ts`'s identical mock). + const projectRefResolver = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? LEGACY_VALID_REF)), + loadProjectRef: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + }); + const proxyCalls: Array<{ args: ReadonlyArray; env?: Record }> = []; const proxyCaptureCalls: Array<{ args: ReadonlyArray; env?: Record }> = []; @@ -170,16 +236,24 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const baseLayer = Layer.mergeAll( + // `BunServices.layer` is listed FIRST so every fake service layer below (most + // importantly `shadowSpawner.layer`'s fake `ChildProcessSpawner`) OVERRIDES its + // real implementation — `Layer.mergeAll` is last-wins on a shared service, + // matching `start.integration.test.ts`'s own established ordering. + BunServices.layer, out.layer, telemetry.layer, cache.layer, seam, edge, docker, - dbConnection, + shadowDbConnection.layer, + shadowSpawner.layer, + alwaysReadyHttpClientLayer, resolver, + projectRefResolver, proxy, - mockLegacyCliConfig({ workdir, projectId: Option.some("test") }), + mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed( LegacyNetworkIdFlag, @@ -189,11 +263,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }), + Layer.succeed(LegacyExperimentalFlag, false), + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), mockRuntimeInfo(), - BunServices.layer, ); - // Merged last so its `FileSystem` overrides `BunServices` (last-wins); `Path` - // still resolves from `BunServices`. + // Merged last so its `FileSystem` overrides everything above (last-wins). const layer = opts.failWriteOnCall === undefined ? baseLayer @@ -204,8 +279,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { out, cache, telemetry, - provisionCalls, - removedContainers, exportCalls, exportCatalogCalls, edgeCalls, @@ -213,6 +286,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { proxyCalls, proxyCaptureCalls, dockerCalls, + shadowSetupJobCalls, + shadowSpawned: shadowSpawner.spawned, + shadowConnectedDatabases: shadowDbConnection.connectedDatabases, + shadowExecCalls: shadowDbConnection.execCalls, }; } @@ -253,13 +330,40 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table players ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags()); - expect(s.provisionCalls).toEqual([{ mode: "diff", targetLocal: true, usePgDelta: false }]); + // The native shadow was created once (one `docker create`) and removed once + // (one `docker rm -f -v`) — see `mockLegacyShadowContainerCliSpawner`. + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); expect(stdout(s.out)).toBe("create table players ();\n\n"); expect(stderr(s.out)).toContain("Creating shadow database..."); expect(stderr(s.out)).toContain("Diffing schemas..."); expect(stderr(s.out)).toContain("Finished supabase db diff on branch"); - expect(s.removedContainers).toEqual(["shadow-1"]); expect(s.telemetry.flushed).toBe(true); + // The shadow's PG15+ one-shot platform-baseline job(s) connect to the shadow over + // Docker's embedded DNS using the shadow container's OWN 12-char short id as `DB_HOST` + // (Go's `container[:12]`, `diff.go:172`) — NOT the real `db` container's name, and not + // some other slice length (a mutation from `.slice(0, 12)` to `.slice(0, 8)` must fail + // this). This is the one shadow-specific parameterization this port exists to get right + // (`legacyBuildShadowSetupDatabaseInput`'s `dbHost`). The default config enables realtime + // (and PG >= 15 by default), so this always exercises at least one one-shot job — + // Realtime's own env sets `DB_HOST` directly; Storage/Auth embed the same host inside a + // `DATABASE_URL`-style connection string instead. + const expectedHost = LEGACY_FAKE_SHADOW_CONTAINER_ID.slice(0, 12); + expect(s.shadowSetupJobCalls.length).toBeGreaterThan(0); + let sawHost = false; + for (const call of s.shadowSetupJobCalls) { + if (call.env["DB_HOST"] !== undefined) { + expect(call.env["DB_HOST"]).toBe(expectedHost); + sawHost = true; + } + for (const value of Object.values(call.env)) { + if (value.includes("@") && value.includes(":")) { + expect(value).toContain(`@${expectedHost}:`); + sawHost = true; + } + } + } + expect(sawHost).toBe(true); }).pipe(Effect.provide(s.layer)); }); @@ -267,12 +371,103 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), schema: ["public"] })); - expect(s.provisionCalls).toEqual([{ mode: "diff", targetLocal: true, usePgDelta: true }]); + // pg-delta selection is observable via the edge-runtime script it runs. + expect(s.edgeCalls[0]?.script).toContain("renderPlanFiles"); expect(stderr(s.out)).toContain("Diffing schemas: public"); expect(stdout(s.out)).toBe("create table p ();\n\n"); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "mounts the pg-delta Deno-cache volume by the config/workdir-resolved project id, not just SUPABASE_PROJECT_ID (review: PRRT_kwDOErm0O86XAlIw)", + () => { + // No `SUPABASE_PROJECT_ID` env and no `supabase/config.toml` `project_id` — Go's + // `Config.ProjectId` falls back to the workdir basename (`pkg/config/config.go:563-570`) + // and `UpdateDockerIds` names the edge-runtime volume from that already-sanitized value + // (`internal/utils/config.go:57-76`). Before the fix, `ctx.projectId` came from + // `LegacyCliConfig.projectId` alone (env-only) and resolved to `""`, mounting + // `supabase_edge_runtime_:/root/.cache/deno:rw` regardless of the real project. + const s = setup(tmp.current, { + diffSql: "create table p ();\n", + projectId: Option.none(), + }); + const expectedProjectId = basename(tmp.current); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); + expect(s.edgeCalls[0]?.binds).toContain( + `supabase_edge_runtime_${expectedProjectId}:/root/.cache/deno:rw`, + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a linked [remotes.]'s own project_id outranks a conflicting SUPABASE_PROJECT_ID for the pg-delta Deno-cache volume (review: PRRT_kwDOErm0O86XI1w8)", + () => { + // `legacyReadDbToml` already gates `cfg.projectId` behind `remoteOverrideKeys` so it + // reflects the matched remote's OWN `project_id` (review: PRRT_kwDOErm0O86XHGDL) — but + // `legacyResolveLocalProjectId` tries `cliConfig.projectId` (raw, ungated env) FIRST, so + // an ambient `SUPABASE_PROJECT_ID` that differs from the matched remote must be + // suppressed here too, or it silently wins back over the already-gated `cfg.projectId`. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[remotes.staging]", 'project_id = "abcdefghijklmnopqrst"', ""].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "create table remote ();\n", + // Simulates an ambient `SUPABASE_PROJECT_ID` scoped to an unrelated (e.g. local) + // project — must NOT win over the matched remote's own `project_id`. + projectId: Option.some("unrelated-env-project"), + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ linked: Option.some(true), usePgDelta: Option.some(true) })); + expect(s.edgeCalls[0]?.binds).toContain( + "supabase_edge_runtime_abcdefghijklmnopqrst:/root/.cache/deno:rw", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("PG14: provisions a shadow via the SQL-exec init path (no PG15+ one-shot jobs)", () => { + // Go's own shadow test coverage hardcodes PG14 (`diff_test.go`); the PG15+ short-id + // DNS resolution path was verified separately (empirical Docker probe, see the + // task's own header) — this covers the OTHER major-version branch of the SAME + // `legacySetupDatabase` pipeline, which execs SQL directly via the session + // instead of the three one-shot `LegacyDockerRun` jobs. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 14\n"); + const s = setup(tmp.current, { diffSql: "create table pg14 ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + expect(stdout(s.out)).toBe("create table pg14 ();\n\n"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + // PG14's `legacyStartInitSchemaPre15` execs SQL over the session directly — + // no one-shot `LegacyDockerRun` jobs (Go's `initSchema15` never runs). + expect(s.dockerCalls).toEqual([]); + expect(s.shadowExecCalls.length).toBeGreaterThan(0); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "removes the shadow even when its own platform-baseline setup fails midway (ok-sentinel cleanup)", + () => { + // Mirrors Go's `ok`-sentinel + `defer` pattern (`shadow.go:42-47`): once the + // shadow container is created, ANY later failure (here, a PG15+ one-shot + // platform-baseline job exiting non-zero) still removes it. + const s = setup(tmp.current, { diffSql: "create table x ();\n", failShadowSetupJob: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags()).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("a linked [remotes.] block enabling pg-delta selects the pg-delta engine", () => { // Go loads the project ref before LoadConfig on the linked path, merging the // matching [remotes.] block before experimental.pgdelta.enabled is read @@ -301,13 +496,54 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ linked: Option.some(true) })); - expect(s.provisionCalls[0]?.usePgDelta).toBe(true); - // The shadow is provisioned with the resolved ref so the `db __shadow` child - // merges the same `[remotes.]` override into the shadow baseline. - expect(s.provisionCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); + // pg-delta selection (ref-aware: read from the remote-merged `cfg.pgDelta`) is + // observable via the edge-runtime script the diff runs. + expect(s.edgeCalls[0]?.script).toContain("renderPlanFiles"); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "a linked [remotes.] db.major_version override reaches the shadow's OWN container spec, not just cfg", + () => { + // Go remote-merges the WHOLE config uniformly on the linked path (`LoadConfig` seeds + // `flags.ProjectRef` before every field read) — the shadow's container spec (image, + // JWT secret, root key, db.settings, service enabled-for-setup flags) must reflect the + // matched `[remotes.]` override too, not just the `cfg`/`toml` read used for + // pg-delta/schema_paths. `major_version` is a clean, directly-observable probe: PG <= 14 + // is the ONLY branch that emits a `--tmpfs` flag on the shadow's `docker create` argv + // (`legacyBuildShadowPostgresContainerSpec`) — a base config of 17 (>= 15, no tmpfs) + // overridden by a remote block's `major_version = 14` must flip that flag on. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "alter table x;\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ linked: Option.some(true) })); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); + // The PG15+ one-shot platform-baseline jobs (`initSchema15`) never run for PG14 — + // it execs SQL directly over the session instead — corroborating the same override. + expect(s.dockerCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("the base config (default local target) does not merge a remote block", () => { // The default db diff target is local; Go never calls LoadProjectRef for local, // so a [remotes.] override must be ignored and the base engine (migra) wins. @@ -329,9 +565,8 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table players ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags()); - expect(s.provisionCalls[0]?.usePgDelta).toBe(false); - // The local default never passes a ref, so the shadow uses base config. - expect(s.provisionCalls[0]?.projectRef).toBeUndefined(); + // The local default never merges a remote block, so the base (migra) engine wins. + expect(s.edgeCalls[0]?.script).not.toContain("renderPlanFiles"); }).pipe(Effect.provide(s.layer)); }); @@ -343,22 +578,57 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ linked: Option.some(true) })); - expect(s.provisionCalls[0]?.targetLocal).toBe(false); expect(s.cache.cached).toBe(true); }).pipe(Effect.provide(s.layer)); }); - it.effect("uses the seam's target override for the local declarative branch", () => { - const s = setup(tmp.current, { - targetOverride: "postgres://postgres:postgres@127.0.0.1:54320/contrib_regression", - diffSql: "create table o ();\n", - }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags()); - expect(stdout(s.out)).toBe("create table o ();\n\n"); - expect(s.removedContainers).toEqual(["shadow-1"]); - }).pipe(Effect.provide(s.layer)); - }); + it.effect( + "caches the linked ref even when the merged config fails to load afterward (review: PRRT_kwDOErm0O86XLe6s)", + () => { + // Go's `ensureProjectGroupsCached` (`cmd/root.go:212-233`) reads the GLOBAL + // `flags.ProjectRef` singleton `LoadProjectRef` sets as a side effect, and runs + // unconditionally after `rootCmd.ExecuteC()` regardless of whether the command itself + // errored — so a ref resolved via `LoadProjectRef` gets cached even when a LATER step + // (here, `legacyReadDbToml`'s own config-load) fails. `db.migrations.enabled = "notabool"` + // fails `legacyReadDbToml`'s own bool parse AFTER the ref is already known, exercising + // exactly that gap. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[db.migrations]", 'enabled = "notabool"', ""].join("\n"), + ); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "alter table x;\n", + }); + return Effect.gen(function* () { + const exit = yield* legacyDbDiff(flags({ linked: Option.some(true) })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "provisions a local-target declarative shadow and diffs against the override database", + () => { + // A declarative schema file under supabase/schemas makes `loadDeclaredSchemas` + // non-empty, so the native `--target-local` branch redirects the diff target to + // a second (contrib_regression) database on the SAME shadow container. + mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); + const s = setup(tmp.current, { diffSql: "create table o ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + expect(stdout(s.out)).toBe("create table o ();\n\n"); + // The declarative-schema file was migrated into the contrib_regression override. + expect(s.shadowConnectedDatabases).toContain("contrib_regression"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); it.effect("delegates --use-pgadmin to the Go binary (telemetry disabled on the child)", () => { const s = setup(tmp.current); @@ -367,7 +637,8 @@ describe("legacy db diff", () => { 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" }); - expect(s.provisionCalls).toEqual([]); + // 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)); }); @@ -399,6 +670,40 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "validates the shadow's own local config (api.tls cert file) BEFORE resolving the connection", + () => { + // `db.major_version` above is caught by `cfg` (`legacyReadDbToml`'s "D" pipeline), + // which already runs ahead of `resolver.resolve()`. `api.tls` is "L only" — `cfg` + // only tracks its dotted keys for remote-override gating, it never reads the cert/key + // files (see `legacyBuildLocalDbContainerInputs`'s doc comment) — so this is the ONE + // config error only `legacyBuildLocalDbContainerInputs`'s own validation catches. Go + // validates it as part of `LoadConfig`, in the root `PersistentPreRunE`, strictly + // before `NewDbConfigWithPassword` (`resolver.resolve()`'s parity target) ever runs + // (review: PRRT_kwDOErm0O86XIUK1) — so `resolverCalls` must stay empty here, proving + // the shadow's config validation ran first, not just that the command failed. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[api]", + "enabled = true", + "[api.tls]", + "enabled = true", + 'cert_path = "missing-cert.pem"', + 'key_path = "missing-key.pem"', + "", + ].join("\n"), + ); + const s = setup(tmp.current, { diffSql: "create table x ();\n" }); + return Effect.gen(function* () { + const error = yield* legacyDbDiff(flags()).pipe(Effect.flip); + expect(error.message).toContain("failed to read TLS cert"); + expect(s.resolverCalls).toHaveLength(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("re-quotes a comma-containing schema when delegating the diff", () => { // 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 @@ -622,7 +927,7 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("local"), to: Option.some("linked") })); // Explicit mode is pg-delta and never provisions a shadow. - expect(s.provisionCalls).toEqual([]); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); expect(stdout(s.out)).toBe("create table e ();\n"); }).pipe(Effect.provide(s.layer)); }); @@ -686,16 +991,18 @@ describe("legacy db diff", () => { ); it.effect("explicit --from migrations resolves a shadow catalog natively", () => { - // CLI-1959: the migrations ref now resolves via `provisionShadow` (Go's - // unchanged `db __shadow --mode diff`) + a native pg-delta catalog export, - // instead of the retired `exportCatalog({mode:"migrations"})` seam call. + // CLI-1959 (cache mechanics) + CLI-1956 (shadow provisioning): the migrations + // ref now resolves via the SAME native `legacyCreateShadowDatabase`/ + // `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db + // diff`'s own shadow uses, not the retired `db __shadow` seam — a shadow is + // created and torn down (`s.shadowSpawned`), and no seam `exportCatalog` call + // is made (unlike the "declarative"/"baseline" modes, still seam-backed). const s = setup(tmp.current, { diffSql: "create table m ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); expect(s.exportCalls).toEqual([]); - expect(s.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); // `resolveMigrationsCatalogRef` (Go's `explicit.go:88-126`) calls the shadow // primitives directly, without `DiffDatabase`'s own progress line — unlike // `db schema declarative sync`'s `getMigrationsCatalogRef`, which DOES print @@ -722,7 +1029,7 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table m ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); - expect(s.provisionCalls).toEqual([]); + expect(s.shadowSpawned).toEqual([]); expect(s.exportCalls).toEqual([]); const diffCall = s.edgeCalls.find((c) => c.script.includes("renderPlanFiles")); expect(diffCall?.env["SOURCE"]).toBe( @@ -736,7 +1043,26 @@ describe("legacy db diff", () => { "explicit --from linked --to migrations provisions the shadow with the linked ref", () => { // Go resolves linked first (LoadConfig merges [remotes.]), so the later - // migrations catalog is built from the remote-merged config (explicit.go). + // migrations catalog is built from the remote-merged config (explicit.go) — + // and the migrations shadow's OWN container spec must reflect it too, not + // just the pg-delta ref (same probe as "a linked [remotes.] + // db.major_version override reaches the shadow's OWN container spec" above: + // PG <= 14 is the only branch that emits `--tmpfs` on `docker create` argv). + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst", @@ -744,15 +1070,31 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("linked"), to: Option.some("migrations") })); - const migrations = s.provisionCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(migrations?.projectRef).toBe("abcdefghijklmnopqrst"); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); }, ); it.effect("explicit --from migrations --to linked provisions the shadow with base config", () => { // Migrations is resolved BEFORE linked here, so Go's LoadConfig(ref) hasn't run - // yet — the catalog must use base config (no ref forwarded), matching order. + // yet — the catalog (and its shadow's own container spec) must use base config + // (no ref forwarded), matching order. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst", @@ -760,16 +1102,31 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("linked") })); - const migrations = s.provisionCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(migrations?.projectRef).toBeUndefined(); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).not.toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); }); it.effect("explicit --from local --to migrations --linked seeds the merged config", () => { // Go's root ParseDatabaseConfig runs LoadProjectRef+LoadConfig for a changed // --linked before RunExplicit, leaving the config remote-merged — so the - // migrations catalog (and local refs/format options) use the linked override - // even though neither explicit ref is itself `linked`. + // migrations catalog's shadow (and local refs/format options) use the linked + // override even though neither explicit ref is itself `linked`. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst", @@ -783,8 +1140,8 @@ describe("legacy db diff", () => { linked: Option.some(true), }), ); - const migrations = s.provisionCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(migrations?.projectRef).toBe("abcdefghijklmnopqrst"); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); }); @@ -832,7 +1189,7 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some(""), to: Option.some("") })); // Reaching the native path proves it didn't enter explicit mode and error. - expect(s.provisionCalls).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(stdout(s.out)).toBe("create table e ();\n\n"); }).pipe(Effect.provide(s.layer)); }); @@ -954,4 +1311,45 @@ describe("legacy db diff", () => { }); }).pipe(Effect.provide(s.layer)); }); + + it.live( + "removes the shadow container on a SIGINT-style interruption during the health wait, without waiting for the health-check timeout", + () => { + // Regression test for the acquireUseRelease restructuring (review: + // PRRT_kwDOErm0O86XMrID): an earlier shape passed the ENTIRE + // `legacyPrepareShadowSource` (create -> health-wait -> migrate -> + // declarative-apply) as `acquireUseRelease`'s `acquire`, which Effect's + // `uninterruptibleMask` (no `restore` around `acquire`) made completely + // uninterruptible — a SIGINT landing during the health wait (which can run for + // up to 30 real seconds, `LEGACY_HEALTH_CHECK_TIMEOUT_SECONDS`) was silently + // swallowed until the health check gave up on its own, unlike Go's single + // cancellable `ctx`. `acquire` is now ONLY `legacyCreateShadowDatabase` + // (container creation); the health wait runs inside the interruptible `use` + // phase instead, so a `Fiber.interrupt` here must land promptly. + const s = setup(tmp.current, { neverHealthyShadow: true }); + return Effect.gen(function* () { + const fiber = yield* legacyDbDiff(flags()).pipe( + Effect.provide(s.layer), + Effect.forkChild({ startImmediately: true }), + ); + // Wait until the shadow's own health check has actually probed the + // never-healthy container at least once — proving the fiber is genuinely + // suspended inside `legacyWaitForHealthyServices`'s retry loop, not merely + // past the `create` call. + while (!s.shadowSpawned.some((c) => c.args[0] === "container" && c.args[1] === "inspect")) { + yield* Effect.sleep("5 millis"); + } + // `Fiber.interrupt` only resolves once the target fiber (and its finalizers, + // including `legacyRemoveShadowDatabase`) has fully completed — if `acquire` + // still covered the health wait, this call would hang for up to 30 real + // seconds (or until this test's own timeout), instead of resolving as soon + // as the in-flight probe's own subprocess call returns. + 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); + // The diff step (past the health wait) was never reached. + expect(s.edgeCalls).toHaveLength(0); + }); + }, + ); }); 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 8c2ab09380..257a0b3746 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; @@ -11,16 +12,21 @@ import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitc import { legacyLinkedDbResolverRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; -import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; /** * 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 the Go shadow-database seam (`provisionShadow`). `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. + * 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). + * `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. * Per the "provide doesn't share to siblings" rule, `LegacyCliConfig` is provided * to every layer that needs it. */ @@ -41,7 +47,7 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( Layer.provide(cliConfig), ); -const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); export const legacyDbDiffRuntimeLayer = Layer.mergeAll( dbConfig, @@ -49,7 +55,7 @@ export const legacyDbDiffRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, - seam, + httpClient, cliConfig, legacyIdentityStitchLayer, legacyTelemetryStateLayer, diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index 8e6fcf5d65..12f2b8c2dd 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -26,12 +26,13 @@ Notes/Delegation section below). ## Files Read -| Path | Format | When | -| -------------------------------------- | ---------- | --------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`) | -| `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | -| `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | -| `/supabase/.temp/project-ref` | plain text | linked ref resolution | +| Path | Format | When | +| ------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`) | +| `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | +| `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | +| `/supabase/.temp/project-ref` | plain text | linked ref resolution | +| `[db.migrations].schema_paths` globs / `/supabase/database/**` (pg-delta declarative dir) / `/supabase/schemas/**` | SQL | migration-style pull against the local target only: 3-source declarative-schema fallback ladder, first non-empty source wins (same as `db diff`) | ## Files Written @@ -46,7 +47,10 @@ Notes/Delegation section below). ## Docker - Edge-runtime container (pg-delta export / pg-delta or migra diff). -- Shadow Postgres container (provisioned + torn down via the Go `db __shadow` seam). +- Shadow Postgres container — provisioned and torn down natively (`legacyPrepareShadowSource` in + `legacy/commands/db/shared/legacy-shadow-source.ts` / `legacyPrepareRawShadow` in + `legacy/shared/db-bootstrap/shadow-database.ts`, which also owns the lower-level primitives + both build on), no longer via a Go seam. - `supabase/migra` container — the migra OOM bash fallback only. - `pg_dump` container — the initial-migra pull's native remote-schema dump (`legacyStreamPgDump`, shared with `db dump`). diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index 75a4716de8..60926901b6 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -1,13 +1,17 @@ import { Clock, Effect, FileSystem, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, + LegacyNetworkIdFlag, legacyResolveExperimentalWithProjectEnv, legacyResolveYesWithProjectEnv, } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +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, legacyBold } from "../../../shared/legacy-colors.ts"; @@ -32,6 +36,19 @@ import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts"; import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts"; +import { + legacyBuildLocalDbContainerInputs, + type LegacyLocalDbContainerInputs, +} from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import { + legacyCreateShadowDatabase, + legacyPrepareRawShadow, + legacyRemoveShadowDatabase, +} from "../../../shared/db-bootstrap/shadow-database.ts"; +import { + legacyResolveLocalProjectId, + legacySanitizeProjectId, +} from "../../../shared/legacy-docker-ids.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { @@ -67,7 +84,10 @@ import { legacyIsPgDeltaDebugEnabled, } from "../../../shared/legacy-pgdelta.ts"; import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; -import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; +import { + legacyPrepareShadowSource, + legacyShadowRunInputFromLocalContainerInputs, +} from "../shared/legacy-shadow-source.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { LegacyDbPullDumpError, @@ -159,7 +179,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; const connection = yield* LegacyDbConnection; - const seam = yield* LegacyDeclarativeSeam; const proxy = yield* LegacyGoProxy; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; @@ -167,6 +186,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; + const debug = yield* LegacyDebugFlag; const cliArgs = yield* CliArgs; // `--yes` OR `SUPABASE_YES` (Go's `viper.GetBool("YES")`, root.go:318-320). Go @@ -249,47 +269,125 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ? "local" : "linked"; - // Go's `ParseDatabaseConfig` resolves the linked ref via the cheap, local-only - // `LoadProjectRef` (flag/env/`.temp/project-ref` file, no network) BEFORE any of - // the fallible work below (`internal/utils/flags/db_url.go:87-92`), and - // `Execute()`'s `PersistentPostRun` caches that ref regardless of what the rest - // of the command does next, including a mid-way failure (`cmd/root.go:170-181, - // 212-233`). Pre-load it here — same pattern as `reset.handler.ts`/`push.handler.ts` - // (CLI-1879) — so the post-run linked-project-cache finalizer still fires even if - // `resolver.resolve()` below fails partway through its login-role/pooler/DNS work - // (`resolved.ref` is only known once `resolve()` *succeeds*, which is too late for - // the finalizer on a failing run otherwise). + // Go's `ParseDatabaseConfig` resolves the linked ref via the hard `LoadProjectRef`, THEN + // reads the `[remotes.]`-merged config (`LoadConfig`, which prints "Loading config + // override" unconditionally the moment a remote matches — `pkg/config/config.go:605`) — + // and only AFTER that calls `NewDbConfigWithPassword`, which does the actual connection + // work (TCP probe / temp-role mint over the Management API, `internal/utils/flags/ + // db_url.go:87-97`). Pre-load the ref and re-read config here, before `resolver.resolve()` + // below, so the override print (and the merged-config validation) happen in that same + // order. Previously this read — and its print — ran AFTER `resolve()`, so a `resolve()` + // failure (bad password, unreachable host, network-ban lookup, …) left the user never + // knowing which `[remotes.*]` block had matched (review: PRRT_kwDOErm0O86XHvYl). `--local`/ + // `--db-url` never merge a remote block, so only the linked path pre-resolves a ref. + let linkedRef: string | undefined; if (connType === "linked") { - const refResolver = yield* LegacyProjectRefResolver; - linkedRefForCache = yield* refResolver.loadProjectRef(Option.none()); + const projectRefResolver = yield* LegacyProjectRefResolver; + linkedRef = yield* projectRefResolver.loadProjectRef(Option.none()); + // Cache the ref the moment it's known, not after `toml`/`localInputs` below (both + // fallible) resolve — Go's `ensureProjectGroupsCached` (`cmd/root.go:212-233`) reads the + // GLOBAL `flags.ProjectRef` singleton `LoadProjectRef` sets as a side effect, and runs + // unconditionally after `rootCmd.ExecuteC()` regardless of whether the command itself + // errored (`cmd/root.go:169-175` never checks `err` before calling it) — so Go caches a + // resolved ref even when a LATER step (config validation, connection, the pull itself) + // fails. Setting `linkedRefForCache` here, right after the ref resolves, reproduces that + // instead of only doing so after `toml`/`localInputs`/`resolver.resolve()` all succeed + // (`diff.handler.ts`'s identical fix). + linkedRefForCache = linkedRef; + } + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + // Build (and validate) the shadow's own local container inputs BEFORE `resolver.resolve()` + // below, not after: `legacyBuildLocalDbContainerInputs` -> `legacyResolveLocalConfigValues`/ + // `legacyResolveDbBootstrapConfig` read/validate fields (e.g. enabled API TLS's cert/key + // files) that `toml` above never touches (`legacy-db-config.toml-read.ts` only tracks their + // dotted keys for remote-override gating, it doesn't read the files). Go performs this exact + // validation as part of `LoadConfig`, in the root `PersistentPreRunE`, strictly before + // `NewDbConfigWithPassword` — `resolver.resolve()`'s own parity target, see that call's doc + // comment below — or `pull.Run`'s `ConnectByConfig` ever run (`internal/utils/flags/ + // db_url.go:87-93` -> `config_path.go:11-12`). Previously this validation ran inside the + // declarative/migration-file branches further down, AFTER both `resolver.resolve()` (a + // linked target's temp-role mint over the Management API) and `connection.connect()` — so a + // config broken only in a field this build reads (e.g. a missing `api.tls.cert_path` file) + // surfaced after those network side effects instead of before them, unlike Go (review: + // PRRT_kwDOErm0O86XIUK1). Skipped for the delegated `--experimental` path: that spawns the + // real Go binary, which performs this exact validation itself in its OWN `PersistentPreRunE` + // — building it here too would run (and, for any WARN branch, print) it twice for the same + // invocation. Kept as an `Option`, not built directly into a bare value, so the two + // non-delegate branches below (declarative and migration-file — the exact set + // `delegatesExperimentalPull` excludes) can unwrap it without an `undefined` check; both + // `Option.getOrThrow` call sites document why that unwrap is always `Some` there. Cheap + // either way: image resolution stays lazy (`resolvePostgresImage`), so this doesn't pull the + // shadow's Docker image yet. + const localInputs: Option.Option = delegatesExperimentalPull + ? Option.none() + : Option.some( + yield* legacyBuildLocalDbContainerInputs( + spawner, + cliConfig.workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + // So the shadow's own container spec reflects the matching `[remotes.]` + // override, same as `toml` above — see `diff.handler.ts`'s identical call site. + connType === "linked" ? linkedRef : undefined, + // `toml`'s OWN remote-override-key tracking (same matched block) — so a + // remote-set bootstrap field isn't re-overridden by a conflicting `SUPABASE_*` + // env var when deriving the shadow's container spec. + toml.remoteOverrideKeys, + ), + ); + const resolved = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver, password: flags.password ?? Option.none(), }); - const linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + if (linkedRef === undefined) { + linkedRef = Option.getOrUndefined(resolved.ref ?? Option.none()); + } if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = legacyToPostgresURL(resolved.conn); - - // Reload config with the resolved linked ref so a matching `[remotes.]` - // block merges before the engine/format/runtime/declarative paths are read — - // Go loads config after `LoadProjectRef` on the linked path - // (`internal/utils/flags/db_url.go:87-97`). `--local`/`--db-url` never merge a - // remote block, so only the linked path passes the ref. - const toml = yield* legacyReadDbToml( - fs, - path, - cliConfig.workdir, - connType === "linked" ? linkedRef : undefined, - ); const ctx: LegacyPgDeltaContext = { - projectId: Option.getOrElse(cliConfig.projectId, () => ""), + // Go's `UpdateDockerIds` derives `EdgeRuntimeId` from the ALREADY-sanitized + // `Config.ProjectId` singleton (`internal/utils/config.go:57-76`, sanitized once by + // `Config.Validate` at config-load time) — `SUPABASE_PROJECT_ID` env override wins, + // then config.toml's `project_id`, then the workdir basename fallback + // (`pkg/config/config.go:563-570`). `cliConfig.projectId` alone is env-only, so a + // project that relies on `config.toml`'s `project_id` (or the workdir-basename + // default) previously resolved to an empty project id here, mounting the WRONG + // `supabase_edge_runtime_` Deno-cache volume — see `legacy-pgdelta.seam.layer.ts`'s + // `ensureLocalDatabaseStarted` for the same resolution already established for this + // command family (review: PRRT_kwDOErm0O86XAlIw). + // + // `toml.appliedRemote !== undefined` suppresses that env argument entirely: `toml.projectId` + // already reflects the matched `[remotes.]` block's own `project_id` at viper's + // override tier (`legacyReadDbToml`'s `remoteOverrideKeys.has("project_id")` gate, review: + // PRRT_kwDOErm0O86XHGDL) — but `legacyResolveLocalProjectId` tries its FIRST argument + // before its second, so passing the raw, ungated `cliConfig.projectId` here re-introduced + // exactly the bug that fix closed for `toml.projectId` itself: an unrelated ambient + // `SUPABASE_PROJECT_ID` would still win over the matched remote's own id, mounting the + // wrong Deno-cache volume for a linked pg-delta pull. Mirrors the same suppression + // `legacy-local-project-context.ts`'s own `legacyLoadLocalProjectContext` already applies, + // and `diff.handler.ts`'s identical fix (review: PRRT_kwDOErm0O86XI1w8). + projectId: legacySanitizeProjectId( + legacyResolveLocalProjectId( + toml.appliedRemote !== undefined ? undefined : Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(toml.projectId), + cliConfig.workdir, + ), + ), cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }; const formatOptions = Option.getOrElse(toml.pgDelta.formatOptions, () => ""); @@ -407,23 +505,60 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy yield* output.raw("Preparing declarative schema export using pg-delta...\n", "stderr"); const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); const declarativeDir = path.resolve(cliConfig.workdir, declarativeDirRel); - const shadow = yield* seam.provisionShadow({ - mode: "declarative", - targetLocal: false, - usePgDelta: true, - schema: flags.schema, - // Linked path only: merge the same `[remotes.]` override into the - // shadow baseline (Go builds the shadow from the remote-merged config). - projectRef: connType === "linked" ? linkedRef : undefined, - }); - const exported = yield* withPoolerFallback(targetUrl, (targetRef) => - legacyDeclarativeExportPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef, - schema: flags.schema, - formatOptions, - }), - ).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + // Built above, before `resolver.resolve()` — see that build's doc comment. + // `Option.getOrThrow` is safe here: `useDeclarative` is true in this branch, and + // `delegatesExperimentalPull` is defined as `!useDeclarative && (...)`, so + // `localInputs` was always built (never the `Option.none()` delegate case) by the + // time this branch runs. + const declLocalInputs = Option.getOrThrow(localInputs); + const resolvedDeclShadowImage = yield* declLocalInputs.resolvePostgresImage; + // `legacyPrepareRawShadow` needs none of the `setup`/declarative-branch fields the + // adapter also returns (a bare shadow never runs `MigrateShadowDatabase`) — its own + // input type (`LegacyShadowConnectionInput`) is structurally narrower, so the extra + // fields are simply never read. + const rawShadowInput = legacyShadowRunInputFromLocalContainerInputs( + declLocalInputs, + resolvedDeclShadowImage, + toml, + fs, + path, + ); + // `Effect.acquireUseRelease`, NOT a separate `yield* legacyCreateShadowDatabase(...)` + // followed by a later `.pipe(Effect.ensuring(...))` (see this file's migration-path + // call site below, and `diff.handler.ts`'s identical call site, for the full + // rationale): the latter shape leaves a gap between the shadow's successful creation + // and the `Effect.ensuring` finalizer actually being attached, where a fiber interrupt + // would skip `legacyRemoveShadowDatabase` and leak the shadow container + its staged + // secret directory. `acquireUseRelease` registers the release finalizer in the same + // uninterruptible continuation the acquire resolves into, matching Go's `defer + // DockerRemove` immediately after successful creation (review: PRRT_kwDOErm0O86XEuqJ). + // + // `acquire` here is ONLY `legacyCreateShadowDatabase` (container creation) — NOT the + // health-wait `legacyPrepareRawShadow` performs; that runs inside the `use` phase + // below instead, so a SIGINT can still interrupt it, matching Go's single cancellable + // `ctx` (see `shadow-database.ts`'s own doc comment on `legacyPrepareRawShadow` for + // the full rationale, review: PRRT_kwDOErm0O86XMrID). + const exported = yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, rawShadowInput), + (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareRawShadow(spawner, handle, rawShadowInput); + return yield* withPoolerFallback(targetUrl, (targetRef) => + legacyDeclarativeExportPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef, + schema: flags.schema, + formatOptions, + }), + ); + }), + (handle) => + legacyRemoveShadowDatabase(spawner, { + containerId: handle.containerId, + secretDirId: handle.secretDirId, + workdir: cliConfig.workdir, + }), + ); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, exported).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), ); @@ -516,6 +651,14 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // `ensureMigrationWritten` (`pull.go:68,263-268`): an empty dump + empty diff // is "in sync", a non-empty dump is a valid initial migration on its own. let seedWroteBytes = false; + + // Built above, before `resolver.resolve()` (see that build's doc comment — it's what + // used to run here, right before the initial-dump write below, but even that was still + // after `resolver.resolve()`/`connection.connect()`). `Option.getOrThrow` is safe here: + // this point is only reached after the `if (delegatesExperimentalPull) { …; return; }` + // check above already returned, so `localInputs` was always built. + const pullLocalInputs = Option.getOrThrow(localInputs); + if (seededFromDump) { yield* legacyMakeDir(fs, path.dirname(migrationPath)).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), @@ -617,80 +760,136 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // For the initial pull (no local migrations) the schema filter is ignored, // matching Go's `diffRemoteSchema(ctx, nil, …)`. const diffSchema = sync.kind === "missing" ? [] : flags.schema; - // Go's `DiffDatabase` emits these to stderr before provisioning + diffing - // (`internal/db/diff/diff.go:189,234-237`); the shadow seam doesn't, so the - // pull handler emits them itself to match the migration-style `db pull` output. - yield* output.raw("Creating shadow database...\n", "stderr"); - const shadow = yield* seam.provisionShadow({ - mode: "diff", - // Mirror Go's `DiffDatabase` → `PrepareShadowSource(ctx, schema, - // utils.IsLocalDatabase(config), …)` (`internal/db/diff/diff.go:190`): - // a local target with declarative schema files gets a second - // `contrib_regression` shadow returned as the target override. - targetLocal: resolved.isLocal, - usePgDelta: usePgDeltaDiff, - schema: diffSchema, - // Linked path only: merge the same `[remotes.]` override into the - // shadow baseline (Go builds the shadow from the remote-merged config). - projectRef: connType === "linked" ? linkedRef : undefined, - }); - const diffOutcome = yield* Effect.gen(function* () { - // Use the declarative target override when present (Go substitutes it - // for the diff target, `diff.go:196-197`); for remote pulls it's - // undefined, so this is the direct target URL as before. - const target = shadow.targetUrlOverride ?? targetUrl; - yield* output.raw( - diffSchema.length > 0 - ? `Diffing schemas: ${diffSchema.join(",")}\n` - : "Diffing schemas...\n", - "stderr", - ); - return yield* withPoolerFallback(target, (targetRef) => - // Wrap the engine choice in a gen so both branches' error/requirement - // channels unify into one `Effect` the helper can retry generically. - Effect.gen(function* () { - if (usePgDeltaDiff) { - // With PGDELTA_DEBUG set, capture the shadow baseline catalog so an - // empty diff can be inspected later (Go's DiffDatabase, - // `internal/db/diff/diff.go:205-214`); a failed export only warns. - const debug = legacyIsPgDeltaDebugEnabled(); - const sourceCatalog = debug - ? 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", - ) - .pipe(Effect.as(undefined)), - ), - ) - : undefined; - const result = yield* legacyDiffPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef, - schema: diffSchema, - formatOptions, - }); - return { - sql: result.sql, - files: result.files, - capture: debug ? { sourceCatalog, stderr: result.stderr } : undefined, - }; - } - const sql = yield* legacyDiffMigra(ctx, { - source: shadow.sourceUrl, - target: targetRef, - schema: diffSchema, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - }); - return { sql, files: undefined, capture: undefined }; - }), - ); - }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + // Go's `diffRemoteSchema` retries the ENTIRE `diff.DiffDatabase` call — shadow + // provisioning included — against the pooler config on an IPv6 failure, not + // just the diff step (`internal/db/pull/pull.go:176-190`): `DiffDatabase` + // prints "Creating shadow database..." and runs `PrepareShadowSource` before + // ever touching the remote/target connection (`internal/db/diff/diff.go:211- + // 217`), so a pooler retry re-prints the creation/diff banners and provisions + // + tears down a second, fresh shadow. Mirror that observable behavior by + // wrapping the full prepare-shadow-then-diff operation in the retried + // closure — each attempt gets its own shadow and its own teardown — instead + // of provisioning one shadow and only retrying the diff engine against it. + const runShadowDiff = (targetRef: string) => + Effect.gen(function* () { + // Go's `DiffDatabase` emits these to stderr before provisioning + diffing + // (`internal/db/diff/diff.go:212,223-226`); `legacyPrepareShadowSource` + // doesn't print its own banner, so the pull handler emits it itself to + // match the migration-style `db pull` output. + yield* output.raw("Creating shadow database...\n", "stderr"); + // Resolved AFTER the banner, inside the retried closure — Go's + // `CreateShadowDatabase` → `utils.DockerStart` (where the postgres image is + // resolved/pulled) runs inside `PrepareShadowSource`, which is itself called + // after `DiffDatabase` prints "Creating shadow database..." above, and is + // re-run fresh on every pooler-retry attempt (see the comment above). Resolving + // it earlier, outside this closure (as `diff.handler.ts`'s sibling call site does + // NOT do — it also resolves after its own banner), would both print nothing on an + // image-resolution failure before the banner and skip re-resolving it on retry. + const resolvedPullShadowImage = yield* pullLocalInputs.resolvePostgresImage; + // Mirror Go's `DiffDatabase` → `PrepareShadowSource(ctx, schema, + // utils.IsLocalDatabase(config), …)` (`internal/db/diff/diff.go:213`): a + // local target with declarative schema files gets a second + // `contrib_regression` shadow returned as the target override. + const shadowInput = { + ...legacyShadowRunInputFromLocalContainerInputs( + pullLocalInputs, + resolvedPullShadowImage, + toml, + fs, + path, + ), + targetLocal: resolved.isLocal, + usePgDelta: usePgDeltaDiff, + // `toml.schemaPathPatterns`, NOT `pullLocalInputs.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) — `toml` above (`legacyReadDbToml`) already + // resolves that env override the same way Go's `utils.Config.Db.Migrations. + // SchemaPaths` does (review: PRRT_kwDOErm0O86XDr4S). + schemaPaths: toml.schemaPathPatterns, + pgDelta: toml.pgDelta, + ctx, + }; + // `Effect.acquireUseRelease`, NOT a separate `yield* legacyCreateShadowDatabase(...)` + // followed by a later `.pipe(Effect.ensuring(...))` (see `diff.handler.ts`'s + // identical call site for the full rationale): the latter shape leaves a gap + // between the shadow's successful creation and the `Effect.ensuring` finalizer + // actually being attached, where a fiber interrupt would skip + // `legacyRemoveShadowDatabase` and leak the shadow container + its staged secret + // directory. `acquireUseRelease` registers the release finalizer in the same + // uninterruptible continuation the acquire resolves into, matching Go's `defer + // DockerRemove` immediately after successful creation (review: PRRT_kwDOErm0O86XDr4Y). + // + // `acquire` here is ONLY `legacyCreateShadowDatabase` (container creation) — NOT + // the health-wait/migrate/declarative-apply `legacyPrepareShadowSource` performs; + // those run inside the `use` phase below instead, so a SIGINT can still interrupt + // them, matching Go's single cancellable `ctx` (see `legacy-shadow-source.ts`'s own + // doc comment on `legacyPrepareShadowSource` for the full rationale, review: + // PRRT_kwDOErm0O86XMrID). + return yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowInput), + (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); + // Use the declarative target override when present (Go substitutes it + // for the diff target, `diff.go:196-197`); for remote pulls it's + // undefined, so this is this attempt's resolved target URL. + const target = shadow.targetUrlOverride ?? targetRef; + yield* output.raw( + diffSchema.length > 0 + ? `Diffing schemas: ${diffSchema.join(",")}\n` + : "Diffing schemas...\n", + "stderr", + ); + if (usePgDeltaDiff) { + // With PGDELTA_DEBUG set, capture the shadow baseline catalog so an + // empty diff can be inspected later (Go's DiffDatabase, + // `internal/db/diff/diff.go:205-214`); a failed export only warns. + const debug = legacyIsPgDeltaDebugEnabled(); + const sourceCatalog = debug + ? 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", + ) + .pipe(Effect.as(undefined)), + ), + ) + : undefined; + const result = yield* legacyDiffPgDelta(ctx, { + sourceRef: shadow.sourceUrl, + targetRef: target, + schema: diffSchema, + formatOptions, + }); + return { + sql: result.sql, + files: result.files, + capture: debug ? { sourceCatalog, stderr: result.stderr } : undefined, + }; + } + const sql = yield* legacyDiffMigra(ctx, { + source: shadow.sourceUrl, + target, + schema: diffSchema, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }); + return { sql, files: undefined, capture: undefined }; + }), + (handle) => + legacyRemoveShadowDatabase(spawner, { + containerId: handle.containerId, + secretDirId: handle.secretDirId, + workdir: cliConfig.workdir, + }), + ); + }); + const diffOutcome = yield* withPoolerFallback(targetUrl, runShadowDiff); const out = diffOutcome.sql; const diffEmpty = out.trim().length === 0; diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 128205371d..b7412430f4 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -1,14 +1,18 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { + LEGACY_VALID_REF, legacyFailWriteStringOnNthCallFsLayer, mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyShadowContainerCliSpawner, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; @@ -19,6 +23,7 @@ import { mockTty, } from "../../../../../tests/helpers/mocks.ts"; import { + LegacyDebugFlag, LegacyDnsResolverFlag, LegacyExperimentalFlag, LegacyNetworkIdFlag, @@ -37,10 +42,16 @@ import { LegacyEdgeRuntimeScript, } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { legacyDbPull } from "./pull.handler.ts"; +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + const EXPORT_JSON = JSON.stringify({ version: 1, mode: "declarative", @@ -71,7 +82,6 @@ interface SetupOpts { readonly pipedAnswers?: ReadonlyArray; readonly yes?: boolean; readonly experimental?: boolean; - readonly shadowTargetOverride?: string; readonly promptConfirmResponses?: ReadonlyArray; readonly resolvedRef?: string; // Fail the first edge-runtime run with this message (the second succeeds with @@ -98,6 +108,10 @@ interface SetupOpts { readonly args?: ReadonlyArray; // When set, the Nth `writeFileString` fails, exercising cleanup-on-failure. readonly failWriteOnCall?: number; + // `LegacyCliConfig.projectId` (Go's `SUPABASE_PROJECT_ID` env-only reader). Defaults to + // `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; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -108,35 +122,16 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const provisionCalls: Array<{ - mode: string; - usePgDelta: boolean; - targetLocal: boolean; - projectRef?: string; - }> = []; - const removedContainers: string[] = []; - const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: () => Effect.succeed("supabase/.temp/pgdelta/x.json"), - ensureLocalDatabaseStarted: () => Effect.void, - ensureLocalPostgresImageCurrent: () => Effect.void, - provisionShadow: ({ mode, usePgDelta, targetLocal, projectRef }) => { - provisionCalls.push({ mode, usePgDelta, targetLocal, projectRef }); - return Effect.succeed({ - container: "shadow-1", - sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: opts.shadowTargetOverride, - }); - }, - removeShadowContainer: (container) => - Effect.sync(() => { - removedContainers.push(container); - }), - }); + // Shadow provisioning is native (CLI-1956): a real docker-spawner fake backs + // container create/start/health-inspect/cleanup. + const shadowSpawner = mockLegacyShadowContainerCliSpawner(); let edgeRunCount = 0; + const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { edgeRunCount += 1; + edgeCalls.push(runOpts); if (opts.edgeFailFirstWith !== undefined && edgeRunCount === 1) { return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: opts.edgeFailFirstWith })); } @@ -160,6 +155,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { runCapture: () => Effect.die("runCapture unused"), runStream: (runOpts, streamOpts) => Effect.gen(function* () { + // The native shadow's PG15+ one-shot platform-baseline jobs + // (`legacyRunStartMigrateJob`) go through this same `runStream`, always + // `skipImageResolve: true` (the real `pg_dump` `runStream` call never sets + // it) — succeed unconditionally so shadow setup itself never fails; this + // suite has no assertions over the one-shot jobs' own output, and they must + // not be counted alongside the real `dumpCalls` this suite DOES assert on. + if (runOpts.skipImageResolve === true) { + return { exitCode: 0, stderr: "" }; + } dumpRunCount += 1; dumpCalls.push({ env: runOpts.env, image: runOpts.image }); if (opts.dumpFailFirstWith !== undefined && dumpRunCount === 1) { @@ -177,27 +181,46 @@ function setup(workdir: string, opts: SetupOpts = {}) { const execLog: string[] = []; const historyUpserts: ReadonlyArray[] = []; - const session = { + const connectedDatabases: Array = []; + // The resolver mock's own target connection always dials port 5432; the native + // shadow (platform baseline, `CREATE_TEMPLATE`, migrations, and — on the + // declarative branch — the `contrib_regression` override) always dials the + // schema-default shadow port (54320) instead — a reliable way to tell "the + // REAL remote/local target's own history upsert" (which `historyUpserts` is + // meant to count) apart from the shadow's OWN internal migration replay (which + // ALSO issues a parameterized `INSERT_MIGRATION_VERSION` query, into its own + // separate in-shadow history table). + const TARGET_PORT = 5432; + const makeSession = (isShadow: boolean) => ({ exec: (sql: string) => Effect.sync(() => void execLog.push(sql)), query: (sql: string, params?: ReadonlyArray) => { if (/SELECT version/u.test(sql)) { return Effect.succeed((opts.remoteVersions ?? []).map((v) => ({ version: v }))); } - if (params !== undefined) historyUpserts.push(params); + if (!isShadow && params !== undefined) historyUpserts.push(params); return Effect.succeed([] as ReadonlyArray>); }, extensionExists: () => Effect.die("extensionExists unused"), copyToCsv: () => Effect.die("copyToCsv unused"), queryRaw: () => Effect.die("queryRaw unused"), - }; + }); + const targetSession = makeSession(false); + const shadowSession = makeSession(true); const dbConnection = Layer.succeed(LegacyDbConnection, { - connect: () => Effect.succeed(session), + connect: (cfg: { readonly database: string; readonly port: number }) => + Effect.sync(() => { + connectedDatabases.push(cfg.database); + return cfg.port === TARGET_PORT ? targetSession : shadowSession; + }), }); const poolerFallbackCalls: unknown[] = []; + const resolveCalls: unknown[] = []; const resolver = Layer.succeed(LegacyDbConfigResolver, { - resolve: ({ connType }) => - Effect.succeed({ + resolve: (resolveFlags) => { + resolveCalls.push(resolveFlags); + const { connType } = resolveFlags; + return Effect.succeed({ conn: { // A direct `db..` host so the pooler-fallback gate // (Go's ProjectRefFromDirectDbHost) matches on the linked path. @@ -209,7 +232,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, isLocal: connType === "local", ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), - }), + }); + }, resolvePoolerFallback: (resolveFlags) => { poolerFallbackCalls.push(resolveFlags); return Effect.succeed( @@ -241,31 +265,38 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); - // The linked ref is pre-loaded (for the post-run cache) before `resolve()`, - // mirroring Go's `LoadProjectRef`-before-`NewDbConfigWithPassword` order (see the - // pre-load block in `pull.handler.ts`, CLI-1879). Default to the same ref the - // `LegacyDbConfigResolver` mock above uses for its `db..…` host so both stay - // consistent unless a test overrides `resolvedRef`. + // The linked ref is now pre-loaded (for the config-override print, ahead of + // `resolver.resolve()`'s own network work — review: PRRT_kwDOErm0O86XHvYl) via + // `LegacyProjectRefResolver`, mirroring the SAME ref `resolver`'s own mock embeds in + // its `db..` connection host above, so both stay consistent regardless of + // whether a test sets `opts.resolvedRef` (mirrors `reset.integration.test.ts`'s + // identical mock). const projectRefResolver = Layer.succeed(LegacyProjectRefResolver, { - resolve: () => Effect.succeed(opts.resolvedRef ?? "abcdefghijklmnopqrst"), - resolveForLink: () => Effect.succeed(opts.resolvedRef ?? "abcdefghijklmnopqrst"), - resolveOptional: () => Effect.succeed(Option.some(opts.resolvedRef ?? "abcdefghijklmnopqrst")), - loadProjectRef: () => Effect.succeed(opts.resolvedRef ?? "abcdefghijklmnopqrst"), - promptProjectRef: () => Effect.succeed(opts.resolvedRef ?? "abcdefghijklmnopqrst"), + resolve: () => Effect.succeed(opts.resolvedRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.resolvedRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.resolvedRef ?? LEGACY_VALID_REF)), + loadProjectRef: () => Effect.succeed(opts.resolvedRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.resolvedRef ?? LEGACY_VALID_REF), }); const baseLayer = Layer.mergeAll( + // `BunServices.layer` is listed FIRST so every fake service layer below (most + // importantly `shadowSpawner.layer`'s fake `ChildProcessSpawner`) OVERRIDES its + // real implementation — `Layer.mergeAll` is last-wins on a shared service, + // matching `start.integration.test.ts`'s own established ordering. + BunServices.layer, out.layer, telemetry.layer, cache.layer, - seam, edge, docker, dbConnection, + shadowSpawner.layer, + alwaysReadyHttpClientLayer, resolver, - proxy, projectRefResolver, - mockLegacyCliConfig({ workdir, projectId: Option.some("test") }), + proxy, + mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin( opts.stdinIsTty ?? false, @@ -273,6 +304,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + Layer.succeed(LegacyDebugFlag, false), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(LegacyNetworkIdFlag, Option.none()), Layer.succeed(LegacyPgDeltaSslProbe, { @@ -281,10 +313,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), Layer.succeed(CliArgs, { args: opts.args ?? [] }), mockRuntimeInfo(), - BunServices.layer, ); - // Merged last so its `FileSystem` overrides `BunServices` (last-wins); `Path` - // still resolves from `BunServices`. + // Merged last so its `FileSystem` overrides everything above (last-wins). const layer = opts.failWriteOnCall === undefined ? baseLayer @@ -293,18 +323,20 @@ function setup(workdir: string, opts: SetupOpts = {}) { return { layer, out, - cache, - provisionCalls, - removedContainers, proxyCalls, proxyCaptureCalls, historyUpserts, execLog, + connectedDatabases, poolerFallbackCalls, + resolveCalls, dumpCalls, + shadowSpawned: shadowSpawner.spawned, get edgeRunCount() { return edgeRunCount; }, + edgeCalls, + cache, }; } @@ -516,7 +548,8 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - expect(s.provisionCalls[0]?.usePgDelta).toBe(false); + // Migra engine selection is proven by `edgeStdout` parsing as raw SQL below + // (a pg-delta selection would instead try — and fail — to `JSON.parse` it). const err = streamText(s.out, "stderr"); // Go's `ConnectByConfig` prints the Connecting line to stderr before dialing // (`internal/utils/connect.go:348`), ahead of any other pull output. @@ -531,6 +564,40 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "validates the shadow's own local config (api.tls cert file) BEFORE resolving the connection", + () => { + // `toml` (`legacyReadDbToml`'s "D" pipeline) only tracks `api.tls`'s dotted keys for + // remote-override gating, it never reads the cert/key files — that read lives in + // `legacyBuildLocalDbContainerInputs`'s own "L" pipeline (see that call's doc comment, + // and `diff.handler.ts`'s identical fix). Go validates it as part of `LoadConfig`, in + // the root `PersistentPreRunE`, strictly before `NewDbConfigWithPassword` + // (`resolver.resolve()`'s parity target) or `pull.Run`'s `ConnectByConfig` ever run + // (review: PRRT_kwDOErm0O86XIUK1) — so `resolveCalls` must stay empty here, proving the + // shadow's config validation ran first, not just that the command failed. + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[api]", + "enabled = true", + "[api.tls]", + "enabled = true", + 'cert_path = "missing-cert.pem"', + 'key_path = "missing-key.pem"', + "", + ].join("\n"), + ); + const s = setup(tmp.current, { remoteVersions: [], edgeStdout: "" }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + expect(error.message).toContain("failed to read TLS cert"); + expect(s.resolveCalls).toHaveLength(0); + expect(s.connectedDatabases).toHaveLength(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("pull --declarative exports declarative files (no migration)", () => { const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { @@ -549,7 +616,10 @@ describe("legacy db pull", () => { expect( existsSync(join(tmp.current, "supabase", "database", "schemas", "public", "t.sql")), ).toBe(true); - expect(s.provisionCalls[0]?.mode).toBe("declarative"); + // Declarative mode's bare shadow (`legacyPrepareRawShadow`) never connects to set + // up a platform baseline or `contrib_regression` template — the only connect is + // the top-level target connect (`resolved.conn`, database "postgres"). + expect(s.connectedDatabases).toEqual(["postgres"]); }).pipe(Effect.provide(s.layer)); }); @@ -616,6 +686,58 @@ describe("legacy db pull", () => { }, ); + it.effect( + "mounts the pg-delta Deno-cache volume by the config/workdir-resolved project id, not just SUPABASE_PROJECT_ID (review: PRRT_kwDOErm0O86XAlIw)", + () => { + // No `SUPABASE_PROJECT_ID` env and no `supabase/config.toml` `project_id` — Go's + // `Config.ProjectId` falls back to the workdir basename (`pkg/config/config.go:563-570`) + // and `UpdateDockerIds` names the edge-runtime volume from that already-sanitized value + // (`internal/utils/config.go:57-76`). Before the fix, `ctx.projectId` came from + // `LegacyCliConfig.projectId` alone (env-only) and resolved to `""`, mounting + // `supabase_edge_runtime_:/root/.cache/deno:rw` regardless of the real project — reachable + // here via the declarative-export path (`legacyDeclarativeExportPgDelta`), which reads + // `ctx.projectId` before any local shadow diff even starts. + const s = setup(tmp.current, { edgeStdout: EXPORT_JSON, projectId: Option.none() }); + const expectedProjectId = basename(tmp.current); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ declarative: Option.some(true) })); + expect(s.edgeCalls[0]?.binds).toContain( + `supabase_edge_runtime_${expectedProjectId}:/root/.cache/deno:rw`, + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a linked [remotes.]'s own project_id outranks a conflicting SUPABASE_PROJECT_ID for the pg-delta Deno-cache volume (review: PRRT_kwDOErm0O86XI1w8)", + () => { + // `legacyReadDbToml` already gates `toml.projectId` behind `remoteOverrideKeys` so it + // reflects the matched remote's OWN `project_id` (review: PRRT_kwDOErm0O86XHGDL) — but + // `legacyResolveLocalProjectId` tries `cliConfig.projectId` (raw, ungated env) FIRST, so + // an ambient `SUPABASE_PROJECT_ID` that differs from the matched remote must be + // suppressed here too, or it silently wins back over the already-gated `toml.projectId` + // (mirrors `diff.integration.test.ts`'s identically-named test). + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[remotes.staging]", 'project_id = "abcdefghijklmnopqrst"', ""].join("\n"), + ); + const s = setup(tmp.current, { + edgeStdout: EXPORT_JSON, + resolvedRef: "abcdefghijklmnopqrst", + // Simulates an ambient `SUPABASE_PROJECT_ID` scoped to an unrelated (e.g. local) + // project — must NOT win over the matched remote's own `project_id`. + projectId: Option.some("unrelated-env-project"), + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ declarative: Option.some(true), linked: Option.some(true) })); + expect(s.edgeCalls[0]?.binds).toContain( + "supabase_edge_runtime_abcdefghijklmnopqrst:/root/.cache/deno:rw", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect( "--declarative --use-pg-delta=false stays in migration mode (Go last-occurrence-wins)", () => { @@ -633,7 +755,6 @@ describe("legacy db pull", () => { yield* legacyDbPull( flags({ declarative: Option.some(true), usePgDelta: Option.some(false) }), ); - expect(s.provisionCalls[0]?.mode).toBe("diff"); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); }, @@ -653,7 +774,6 @@ describe("legacy db pull", () => { yield* legacyDbPull( flags({ declarative: Option.some(false), usePgDelta: Option.some(true) }), ); - expect(s.provisionCalls[0]?.mode).toBe("diff"); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); }, @@ -666,7 +786,12 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true), usePgDelta: Option.some(true) })); - expect(s.provisionCalls[0]?.mode).toBe("declarative"); + // Reaching the declarative write (rather than a migration file / history + // upsert) proves the declarative export path ran. + expect( + existsSync(join(tmp.current, "supabase", "database", "schemas", "public", "t.sql")), + ).toBe(true); + expect(s.historyUpserts.length).toBe(0); }).pipe(Effect.provide(s.layer)); }); @@ -698,8 +823,6 @@ describe("legacy db pull", () => { expect(s.dumpCalls).toHaveLength(1); expect(s.dumpCalls[0]?.env["EXTRA_SED"]).toBe("/^--/d"); expect(s.dumpCalls[0]?.env["EXCLUDED_SCHEMAS"]).toContain("auth"); - // The diff ran against the shadow with the migra engine (no schema filter). - expect(s.provisionCalls[0]?.usePgDelta).toBe(false); // The migration file holds the dump output followed by the appended diff. const dir = join(tmp.current, "supabase", "migrations"); const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); @@ -823,7 +946,7 @@ describe("legacy db pull", () => { const error = yield* legacyDbPull(flags()).pipe(Effect.flip); expect(error.message).toContain("error running container: exit 1"); // The diff pass never ran — the dump failure aborts before provisioning a shadow. - expect(s.provisionCalls).toHaveLength(0); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -1470,24 +1593,28 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - expect(s.provisionCalls[0]?.usePgDelta).toBe(true); + // pg-delta selection is proven by `edgeStdout`'s envelope shape parsing + // successfully below (a migra selection would instead treat it as raw SQL). }).pipe(Effect.provide(s.layer)); }); it.effect("db pull --local provisions a local-target shadow and uses the target override", () => { // Go derives the shadow targetLocal from utils.IsLocalDatabase and substitutes - // the declarative contrib_regression target override (diff.go:190,196-197); - // the native handler must pass targetLocal and honor shadow.targetUrlOverride. + // the declarative contrib_regression target override (diff.go:190,196-197); a + // real declarative schema file makes the native `loadDeclaredSchemas` branch + // non-empty, so `legacyPrepareShadowSource` redirects the diff target to the + // shadow's own `contrib_regression` override database. seedMigration(tmp.current, "20240101000000"); + mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, - shadowTargetOverride: "postgres://postgres:postgres@127.0.0.1:54320/contrib_regression", }); return Effect.gen(function* () { yield* legacyDbPull(flags({ local: Option.some(true) })); - expect(s.provisionCalls[0]?.targetLocal).toBe(true); + expect(s.connectedDatabases).toContain("contrib_regression"); // A local target prints the local wording (Go's `IsLocalDatabase` branch in // `ConnectByConfigStream`, `internal/utils/connect.go:344-346`). expect(streamText(s.out, "stderr")).toContain("Connecting to local database...\n"); @@ -1597,18 +1724,98 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags({ linked: Option.some(true) })); - expect(s.provisionCalls[0]?.usePgDelta).toBe(true); - // The resolved ref is forwarded to the shadow so the `db __shadow` child - // merges the same `[remotes.]` override into the shadow baseline. - expect(s.provisionCalls[0]?.projectRef).toBe("abcdefghijklmnopqrst"); + // pg-delta selection is ref-aware (read from the remote-merged `toml.pgDelta`) + // and is proven by `edgeStdout`'s envelope shape parsing successfully below. + expect(streamText(s.out, "stderr")).toMatch( + /Schema written to supabase[/\\]migrations[/\\]\d{14}_remote_schema\.sql\n/u, + ); }).pipe(Effect.provide(s.layer)); }); + it.effect( + "caches the linked ref even when the merged config fails to load afterward (review: PRRT_kwDOErm0O86XLe6s)", + () => { + // Go's `ensureProjectGroupsCached` (`cmd/root.go:212-233`) reads the GLOBAL + // `flags.ProjectRef` singleton `LoadProjectRef` sets as a side effect, and runs + // unconditionally after `rootCmd.ExecuteC()` regardless of whether the command itself + // errored — so a ref resolved via `LoadProjectRef` gets cached even when a LATER step + // (here, `legacyReadDbToml`'s own config-load) fails. `db.migrations.enabled = "notabool"` + // fails `legacyReadDbToml`'s own bool parse AFTER the ref is already known, exercising + // exactly that gap (`diff.integration.test.ts`'s identical fix/test). + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[db.migrations]", 'enabled = "notabool"', ""].join("\n"), + ); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + yes: true, + resolvedRef: "abcdefghijklmnopqrst", + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPull(flags({ linked: Option.some(true) })).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "a linked [remotes.] db.major_version override reaches the shadow's OWN container spec, not just toml", + () => { + // Go remote-merges the WHOLE config uniformly on the linked path (`LoadConfig` seeds + // `flags.ProjectRef` before every field read) — the shadow's container spec (image, JWT + // secret, root key, db.settings, service enabled-for-setup flags) must reflect the + // matched `[remotes.]` override too, not just the `toml` read used for + // pg-delta/schema_paths (mirrors `diff.integration.test.ts`'s identically-named test). + // `major_version` is a clean, directly-observable probe: PG <= 14 is the ONLY branch + // that emits a `--tmpfs` flag on the shadow's `docker create` argv + // (`legacyBuildShadowPostgresContainerSpec`) — a base config of 17 (>= 15, no tmpfs) + // overridden by a remote block's `major_version = 14` must flip that flag on. + seedMigration(tmp.current, "20240101000000"); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "alter table x;\n", + yes: true, + resolvedRef: "abcdefghijklmnopqrst", + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ linked: Option.some(true) })); + const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; + expect(createArgs).toContain("--tmpfs"); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("retries the migration-style diff through the IPv4 pooler on an IPv6 error", () => { // Go wraps the linked diff with PoolerFallbackConfig and retries against the // IPv4 pooler when the direct host is unreachable over IPv6 from the container // (internal/db/pull/pull.go, diffRemoteSchema). The first edge run fails with // an IPv6 connectivity error; the retry succeeds and the migration is written. + // + // Go's `diffRemoteSchema` retries the WHOLE `diff.DiffDatabase` call on this + // path, not just the diff engine (`internal/db/diff/diff.go:211-217` runs + // `PrepareShadowSource` and prints "Creating shadow database..."/"Diffing + // schemas..." before ever touching the target connection) — so the pooler + // retry re-provisions and tears down a FRESH shadow and re-prints both + // banners, rather than reusing the first attempt's shadow. Assert that shape + // directly, not just that the migration eventually gets written. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -1621,18 +1828,31 @@ describe("legacy db pull", () => { yield* legacyDbPull( flags({ linked: Option.some(true), diffEngine: Option.some("pg-delta") }), ); - expect(streamText(s.out, "stderr")).toContain("does not support IPv6"); - expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); + const err = streamText(s.out, "stderr"); + expect(err).toContain("does not support IPv6"); + expect(err).toContain("Retrying via the IPv4 connection pooler"); expect(s.edgeRunCount).toBe(2); - expect(streamText(s.out, "stderr")).toMatch( + expect(err).toMatch( /Schema written to supabase[/\\]migrations[/\\]\d{14}_remote_schema\.sql\n/u, ); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(2); + expect( + s.shadowSpawned.filter((c) => c.args[0] === "rm" && c.args.includes("-f")), + ).toHaveLength(2); + expect(err.split("Creating shadow database...")).toHaveLength(3); + expect(err.split("Diffing schemas...")).toHaveLength(3); }).pipe(Effect.provide(s.layer)); }); it.effect("retries the declarative export through the IPv4 pooler on an IPv6 error", () => { // Go's pullDeclarativePgDelta retries DeclarativeExportPgDelta through the - // pooler in the same IPv6 scenario (internal/db/pull/pull.go). + // pooler in the same IPv6 scenario (internal/db/pull/pull.go), but unlike + // diffRemoteSchema/DiffDatabase it calls `diff.PrepareRawShadow` ONCE before + // the retry and only re-runs the export against the same shadow + // (`pull.go:92-115`) — a deliberate asymmetry in Go's own code, not a gap to + // close. Assert the single-shadow-reuse shape so a future change doesn't + // accidentally "fix" this path to double-provision like the migration-style + // diff path correctly does. const s = setup(tmp.current, { edgeFailFirstWith: "error exporting declarative schema:\nnetwork is unreachable", edgeStdout: EXPORT_JSON, @@ -1645,6 +1865,7 @@ describe("legacy db pull", () => { expect(streamText(s.out, "stderr")).toContain( `Declarative schema written to ${join("supabase", "database")}\n`, ); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts index 821fd07acd..0a3028fe79 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; @@ -12,13 +13,17 @@ import { legacyLinkedDbResolverRuntimeLayer } from "../../../shared/legacy-manag import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; -import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; /** - * Runtime layer for `supabase db pull`. Same composition as `db diff`: the - * db-config resolver, the native pg-delta / migra stack (edge-runtime, SSL probe, - * the Go shadow seam), `LegacyDbConnection` (remote connect + `schema_migrations` - * reconciliation / history update), and `LegacyDockerRun` for the migra fallback. + * Runtime layer for `supabase db pull`. The db-config resolver, the native pg-delta / migra + * stack (edge-runtime, SSL probe, `HttpClient` for the native shadow's health-check wait — + * shadow provisioning itself is native, see `commands/db/shared/legacy-shadow-source.ts` / + * `shared/db-bootstrap/shadow-database.ts`), `LegacyDbConnection` (remote connect + + * `schema_migrations` reconciliation / history update), and `LegacyDockerRun` for the migra + * fallback. No `LegacyDeclarativeSeam` — neither `db pull` nor `db diff` has a Go-delegate + * branch that needs it any more (native shadow provisioning replaced the Go seam entirely, + * CLI-1956/CLI-1959); `--use-pgadmin`/`--use-pg-schema` delegate through `LegacyGoProxy` + * instead, not this seam. */ const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); @@ -34,7 +39,7 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( Layer.provide(cliConfig), ); -const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); export const legacyDbPullRuntimeLayer = Layer.mergeAll( dbConfig, @@ -42,7 +47,7 @@ export const legacyDbPullRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, - seam, + httpClient, cliConfig, legacyIdentityStitchLayer, legacyTelemetryStateLayer, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 00455e786d..fea0bce9c4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -3,9 +3,24 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; -import { mockOutput } from "../../../../../../tests/helpers/mocks.ts"; +import { mockLegacyShadowContainerCliSpawner } from "../../../../../../tests/helpers/legacy-mocks.ts"; +import { alwaysReadyHttpClientLayer } from "../../../../../../tests/helpers/legacy-local-reset.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../../../../tests/helpers/mocks.ts"; +import { CliArgs } from "../../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDebugFlag, + LegacyExperimentalFlag, + LegacyNetworkIdFlag, +} from "../../../../../shared/legacy/global-flags.ts"; +import type { LegacyDbTomlValues } from "../../../../shared/legacy-db-config.toml-read.ts"; +import { + LegacyDbConnection, + type LegacyDbSession, + type LegacyPgConnInput, +} from "../../../../shared/legacy-db-connection.service.ts"; +import { LegacyDockerRun } from "../../../../shared/legacy-docker-run.service.ts"; import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, @@ -32,13 +47,6 @@ import { function mockSeam(paths: Record) { const calls: Array<{ mode: LegacyCatalogMode; noCache: boolean }> = []; - const provisionCalls: Array<{ - mode: string; - targetLocal: boolean; - usePgDelta: boolean; - projectRef?: string; - }> = []; - const removedContainers: string[] = []; const layer = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode, noCache }) => { calls.push({ mode, noCache }); @@ -46,24 +54,55 @@ function mockSeam(paths: Record) { }, ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.void, - // The migrations-catalog source now resolves natively (CLI-1959) via - // `legacyGetMigrationsCatalogRef`, which provisions its shadow through this - // EXISTING `provisionShadow` (Go's unchanged `db __shadow --mode diff`) rather - // than the retired `exportCatalog({mode:"migrations"})` seam call. - provisionShadow: ({ mode, targetLocal, usePgDelta, projectRef }) => { - provisionCalls.push({ mode, targetLocal, usePgDelta, projectRef }); - return Effect.succeed({ - container: "shadow-1", - sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: undefined, - }); - }, - removeShadowContainer: (container) => + }); + return { layer, calls }; +} + +/** + * The native shadow-provisioning stack `legacyGetMigrationsCatalogRef`'s + * cache-miss path needs (CLI-1956): the SAME `legacyCreateShadowDatabase`/ + * `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db diff`/ + * `db pull` use for their own shadow, not the retired `db __shadow` seam — see + * `legacy-pgdelta.cache.ts`'s `exportViaShadowCatalog` doc comment. Mirrors + * `diff.integration.test.ts`'s own shadow mocks (`mockLegacyShadowContainerCliSpawner` + * + a fake `LegacyDbConnection`/`LegacyDockerRun`), scoped down to this file's + * lower-level, seam-free tests. + */ +function mockShadowInfra() { + const spawner = mockLegacyShadowContainerCliSpawner(); + const connectedDatabases: Array = []; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: (cfg: LegacyPgConnInput) => Effect.sync(() => { - removedContainers.push(container); + connectedDatabases.push(cfg.database); + const session: LegacyDbSession = { + exec: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return session; }), }); - return { layer, calls, provisionCalls, removedContainers }; + // The shadow's own PG15+ one-shot platform-baseline job(s) — Go's `initSchema15`. + const docker = Layer.succeed(LegacyDockerRun, { + run: () => Effect.die("run unused"), + runCapture: () => Effect.die("runCapture unused"), + runStream: () => Effect.succeed({ exitCode: 0, stderr: "" }), + }); + const layer = Layer.mergeAll( + spawner.layer, + dbConnection, + docker, + mockRuntimeInfo(), + Layer.succeed(LegacyNetworkIdFlag, Option.none()), + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(LegacyExperimentalFlag, false), + Layer.succeed(CliArgs, { args: [] }), + alwaysReadyHttpClientLayer, + ); + return { layer, spawned: spawner.spawned, connectedDatabases }; } function mockEdge(stdout: string) { @@ -105,7 +144,13 @@ const probe = Layer.succeed(LegacyPgDeltaSslProbe, { }); const ctx = (cwd: string, declarativeDir: string): LegacyDeclarativeRunContext => ({ - pgDelta: { projectId: "cferry", cwd, npmVersion: undefined, denoVersion: 2 }, + pgDelta: { + projectId: "cferry", + cwd, + npmVersion: undefined, + denoVersion: 2, + projectEnv: {}, + }, formatOptions: "", declarativeDir, schema: [], @@ -126,6 +171,45 @@ const setupInputs: LegacySetupInputs = { rolesSql: "", }; +// A minimal, valid `LegacyDbTomlValues` — threaded into `legacyGetMigrationsCatalogRef` +// for the migrations-catalog shadow's own container spec (CLI-1956). Matches +// `legacy-db-config.toml-read.ts`'s own unconfigured defaults so this fixture +// doesn't silently drift from what `legacyReadDbToml` would resolve for these +// tests' bare temp dirs (none of them write a `config.toml`). +const toml: LegacyDbTomlValues = { + projectEnv: {}, + envLookup: () => undefined, + apiSchemas: ["public", "graphql_public"], + port: 54322, + shadowPort: 54320, + password: "postgres", + poolerConnectionString: Option.none(), + projectId: Option.none(), + majorVersion: 17, + orioledbVersion: Option.none(), + denoVersion: 2, + pgDelta: { + enabled: false, + declarativeSchemaPath: Option.none(), + formatOptions: Option.none(), + npmVersion: Option.none(), + }, + baseline: { + authEnabled: true, + storageEnabled: true, + realtimeEnabled: true, + apiAutoExposeNewTables: Option.none(), + vaultNames: [], + }, + migrationsEnabled: true, + schemaPaths: [], + schemaPathPatterns: [], + seed: { enabled: true, sqlPaths: [] }, + vault: [], + appliedRemote: undefined, + remoteOverrideKeys: new Set(), +}; + describe("legacyDiffDeclarativeToMigrations", () => { it.effect( "resolves the migrations catalog natively and diffs it against the seam-provisioned declarative catalog", @@ -139,16 +223,16 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\nDROP TABLE z;\n"); const out = mockOutput(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), setupInputs).pipe( + const shadow = mockShadowInfra(); + return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( Effect.tap((result) => Effect.sync(() => { // "declarative" still resolves via the seam; "migrations" no longer does - // (it resolves natively, provisioning through `provisionShadow` instead). + // (it resolves natively, provisioning its shadow the same way `db diff`/ + // `db pull` do — CLI-1956). expect(seam.calls.map((c) => c.mode)).toEqual(["declarative"]); - expect(seam.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); - expect(seam.removedContainers).toEqual(["shadow-1"]); + expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); // No local migrations in the fresh temp dir → the zero-migrations branch // writes (and returns) the platform-baseline catalog, workdir-relative. expect(result.sourceRef).toMatch( @@ -166,7 +250,9 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -193,10 +279,11 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x;\n"); const out = mockOutput(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), setupInputs).pipe( + const shadow = mockShadowInfra(); + return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( Effect.tap((result) => Effect.sync(() => { - expect(seam.provisionCalls).toEqual([]); + expect(shadow.spawned).toEqual([]); expect(result.sourceRef).toBe( join("supabase", ".temp", "pgdelta", `catalog-baseline-${baselineKey}.json`), ); @@ -204,7 +291,9 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -228,6 +317,7 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\n"); const out = mockOutput(); + const shadow = mockShadowInfra(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -236,7 +326,11 @@ describe("legacyDiffDeclarativeToMigrations", () => { legacySetupInputsToken(setupInputs), migrationsHash, ); - const result = yield* legacyDiffDeclarativeToMigrations(ctx(dir, declDir), setupInputs); + const result = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ); expect(result.sourceRef).toMatch( new RegExp( `^supabase[/\\\\]\\.temp[/\\\\]pgdelta[/\\\\]catalog-local-migrations-${key}-\\d+\\.json$`, @@ -244,13 +338,13 @@ describe("legacyDiffDeclarativeToMigrations", () => { ); expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); expect(out.stderrText).toContain("Creating shadow database...\n"); - expect(seam.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); - expect(seam.removedContainers).toEqual(["shadow-1"]); + expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -272,6 +366,7 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x;\n"); const out = mockOutput(); + const shadow = mockShadowInfra(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -285,13 +380,19 @@ describe("legacyDiffDeclarativeToMigrations", () => { legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), ); writeFileSync(cachedPath, '{"cached":true}'); - const result = yield* legacyDiffDeclarativeToMigrations(ctx(dir, declDir), setupInputs); + const result = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ); expect(result.sourceRef).toBe(path.relative(dir, cachedPath)); expect(readFileSync(cachedPath, "utf8")).toBe('{"cached":true}'); - expect(seam.provisionCalls).toEqual([]); + expect(shadow.spawned).toEqual([]); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -313,6 +414,7 @@ describe("legacyDiffDeclarativeToMigrations", () => { }); const edge = mockEdge("ALTER TABLE x;\n"); const out = mockOutput(); + const shadow = mockShadowInfra(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -331,18 +433,19 @@ describe("legacyDiffDeclarativeToMigrations", () => { writeFileSync(cachedPath, '{"cached":true}'); const result = yield* legacyDiffDeclarativeToMigrations( { ...ctx(dir, declDir), noCache: true }, + toml, setupInputs, ); expect(result.sourceRef).toBe( join("supabase", ".temp", "pgdelta", "catalog-nocache-migrations.json"), ); expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(seam.provisionCalls).toEqual([ - { mode: "diff", targetLocal: false, usePgDelta: false, projectRef: undefined }, - ]); + expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }, ); @@ -352,7 +455,12 @@ describe("legacyDiffDeclarativeToMigrations", () => { const seam = mockSeam({ declarative: "d", baseline: "b" }); const edge = mockEdge(""); const out = mockOutput(); - return legacyDiffDeclarativeToMigrations(ctx(dir, join(dir, "missing")), setupInputs).pipe( + const shadow = mockShadowInfra(); + return legacyDiffDeclarativeToMigrations( + ctx(dir, join(dir, "missing")), + toml, + setupInputs, + ).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -364,11 +472,13 @@ describe("legacyDiffDeclarativeToMigrations", () => { ); } expect(seam.calls).toEqual([]); - expect(seam.provisionCalls).toEqual([]); + expect(shadow.spawned).toEqual([]); rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(seam.layer, edge.layer, probe, out.layer, BunServices.layer)), + Effect.provide( + Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + ), ); }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts index c423d987d3..e739fc8e2e 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts @@ -9,6 +9,7 @@ import { type LegacySetupInputs, legacyGetMigrationsCatalogRef, } from "../../../../shared/legacy-pgdelta.cache.ts"; +import type { LegacyDbTomlValues } from "../../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDeclarativeDiffError } from "./declarative.errors.ts"; import { LegacyDeclarativeSeam } from "../../shared/legacy-pgdelta.seam.service.ts"; import { legacyFindDropStatements } from "../../../../shared/legacy-sql-split.ts"; @@ -45,12 +46,24 @@ export interface LegacyDeclarativeSyncResult { * Mirrors Go's `DiffDeclarativeToMigrations` (`declarative.go:170`): the * declarative catalog (target) is still provisioned via the Go seam (shadow DB + * `SetupDatabase` + declarative apply); the migrations catalog (source) resolves - * natively (CLI-1959) via `legacyGetMigrationsCatalogRef`, which mirrors Go's - * `getMigrationsCatalogRef` (`declarative.go:368-430`) exactly. Both are then - * diffed natively with pg-delta, as before. + * natively (CLI-1959 cache mechanics) via `legacyGetMigrationsCatalogRef`, which + * mirrors Go's `getMigrationsCatalogRef` (`declarative.go:368-430`) exactly — + * including its own shadow provisioning, which is now ALSO native (CLI-1956: the + * same `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`/ + * `legacyRemoveShadowDatabase` primitives `db diff`/`db pull` use for their own + * shadow, not the retired `db __shadow` seam — see + * `legacy-pgdelta.cache.ts`'s `exportViaShadowCatalog` doc comment). Both catalogs + * are then diffed natively with pg-delta, as before. + * + * `toml` is the caller's own already-loaded `config.toml` read + * (`legacyReadDbToml`'s result), threaded through to + * `legacyGetMigrationsCatalogRef` for the migrations-catalog shadow's own + * container spec — distinct from `setupInputs`, the cache-key/baseline-setup + * subset of the same config. */ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, + toml: LegacyDbTomlValues, setupInputs: LegacySetupInputs, ) { const fs = yield* FileSystem.FileSystem; @@ -67,7 +80,7 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( ); } - const sourceRef = yield* legacyGetMigrationsCatalogRef(fs, path, run.pgDelta, setupInputs, { + const sourceRef = yield* legacyGetMigrationsCatalogRef(fs, path, run.pgDelta, toml, setupInputs, { noCache: run.noCache, ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index 25cce1ccbe..2265764b39 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -136,6 +136,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // Merged config's deno_version (re-loaded with the linked ref above on // `--linked`), so pg-delta runs under the remote-configured Deno image. denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }, formatOptions: Option.getOrElse(toml.pgDelta.formatOptions, () => ""), declarativeDir, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 389df21455..36f97641f5 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -151,8 +151,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { : Effect.void, ), ), - provisionShadow: () => Effect.die("provisionShadow not used in declarative tests"), - removeShadowContainer: () => Effect.void, }); const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 35b3ac0534..522daa9e92 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -11,7 +11,7 @@ as a new timestamped migration. | `/supabase/.temp/pgdelta-version` | plain text | always — pins the `@supabase/pg-delta` npm version | | `/supabase/.temp/edge-runtime-version` | plain text | always — pins the edge-runtime image tag | | `/supabase/database/**/*.sql` (declarative dir) | SQL | always — must exist (else error) | -| `/supabase/migrations/*.sql` | SQL | migrations-catalog resolution (native, CLI-1959) — hashed for the cache key and, on a miss, replayed onto the shadow via `db __shadow --mode diff` | +| `/supabase/migrations/*.sql` | SQL | migrations-catalog resolution (native, CLI-1959) — hashed for the cache key and, on a miss, replayed onto a natively-provisioned shadow (CLI-1956) | | `/supabase/roles.sql` | SQL | native migrations-catalog cache key (setup-inputs token; empty when absent) | | `/supabase/.temp/pgdelta/*.json` | JSON | migrations catalog cache (native, CLI-1959); declarative catalog cache (still the Go seam) | @@ -25,12 +25,12 @@ as a new timestamped migration. ## Subprocesses / Containers -| What | When | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| `supabase-go db __shadow --mode diff` (seam, unchanged) — shadow Postgres + `SetupDatabase` + apply migrations; the catalog itself is exported natively via edge-runtime (CLI-1959 — no longer the hidden `db schema declarative __catalog --mode migrations` subprocess) | migrations-catalog cache miss only | -| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | -| Edge-runtime container running the pg-delta diff Deno script, and (on a migrations-catalog cache miss) the pg-delta catalog-export Deno script | always / cache miss | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` (CLI-2062: in-process, no `supabase-go` child) — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| What | When | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Natively-provisioned shadow Postgres container (CLI-1956 — `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`, no longer a `supabase-go db __shadow` subprocess) + native migrate; the catalog itself is exported natively via edge-runtime (CLI-1959 — no longer the hidden `db schema declarative __catalog --mode migrations` subprocess) | migrations-catalog cache miss only | +| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | +| Edge-runtime container running the pg-delta diff Deno script, and (on a migrations-catalog cache miss) the pg-delta catalog-export Deno script | always / cache miss | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` (CLI-2062: in-process, no `supabase-go` child) — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables @@ -84,12 +84,12 @@ are mutually exclusive. cycle rather than firing a second one from a `supabase-go` child). - **Architecture:** the migrations-catalog diff source resolves natively (CLI-1959): the setup-inputs-folded cache key, the zero-local-migrations → platform-baseline - reuse, and the pg-delta catalog export are all native TS; only the shadow-database - platform-baseline provisioning + migrations apply still runs via the bundled - `supabase-go`, reusing the SAME `db __shadow --mode diff` seam call `db diff` - uses (not a `__catalog`-specific shadow). The declarative-catalog diff target - still provisions its shadow-database platform baseline (and applies declarative - files) via the hidden `db schema declarative __catalog --mode declarative` seam, - since neither a baseline-only shadow nor `pgdelta.ApplyDeclarative` has a native - TS port yet (tracked by CLI-1956/CLI-1823). The diff itself is native pg-delta - either way. + reuse, and the pg-delta catalog export are all native TS; the shadow-database + platform-baseline provisioning + migrations apply is native too now (CLI-1956 — + `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`, the SAME native primitives + `db diff` uses for its own shadow, not a `__catalog`-specific one). The + declarative-catalog diff target still provisions its shadow-database platform + baseline (and applies declarative files) via the hidden `db schema declarative +__catalog --mode declarative` seam, since neither a baseline-only shadow nor + `pgdelta.ApplyDeclarative` has a native TS port yet (tracked by CLI-1823). The diff + itself is native pg-delta either way. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index c7f0ac387a..958890640c 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -147,6 +147,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }, formatOptions: Option.getOrElse(toml.pgDelta.formatOptions, () => ""), declarativeDir, @@ -277,6 +278,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); const result: LegacyDeclarativeSyncResult = yield* legacyDiffDeclarativeToMigrations( run, + toml, setupInputs, ).pipe( Effect.tapError((error) => diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index c4995d51df..517901de8e 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -38,7 +38,10 @@ import { LegacyPlatformApi } from "../../../../../auth/legacy-platform-api.servi import { LegacyPlatformApiFactory } from "../../../../../auth/legacy-platform-api-factory.service.ts"; import { legacyDockerRunLayer } from "../../../../../shared/legacy-docker-run.layer.ts"; import { LegacyDbConfigResolver } from "../../../../../shared/legacy-db-config.service.ts"; -import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../../../shared/legacy-db-connection.service.ts"; import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, @@ -104,13 +107,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { // so tests can assert output ordering relative to the exports (e.g. the bootstrap's // written-to line lands after the declarative warm, before the diff's exports). const exportCatalogCalls: Array<{ mode: string; rawChunksAt: number }> = []; - // The migrations-catalog source now resolves natively (CLI-1959) via - // `legacyGetMigrationsCatalogRef`, which provisions its shadow through - // `provisionShadow` (Go's unchanged `db __shadow --mode diff`) instead of the - // retired `exportCatalog({mode:"migrations"})` seam call. "baseline"/ - // "declarative" still go through `exportCatalog`. - const provisionShadowCalls: Array<{ mode: string; targetLocal: boolean; rawChunksAt: number }> = - []; + // The migrations-catalog source now resolves natively (CLI-1959 cache mechanics + // + CLI-1956 shadow provisioning) via `legacyGetMigrationsCatalogRef`, which + // provisions its shadow through the SAME `legacyCreateShadowDatabase`/ + // `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db + // diff`/`db pull` use for their own shadow — via `child.layer`/ + // `legacyDockerRunLayer` below (the same real container-lifecycle mocks + // `legacyResetLocalDatabase`'s own recovery-reset flow already needs), not the + // retired `db __shadow` seam. "baseline"/"declarative" still go through + // `exportCatalog`. const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode }) => Effect.sync(() => { @@ -132,16 +137,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { : Effect.void, ), ), - provisionShadow: ({ mode, targetLocal }) => - Effect.sync(() => { - provisionShadowCalls.push({ mode, targetLocal, rawChunksAt: out.rawChunks.length }); - return { - container: "shadow-1", - sourceUrl: "postgres://postgres:postgres@127.0.0.1:54320/postgres", - targetUrlOverride: undefined, - }; - }), - removeShadowContainer: () => Effect.void, }); const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { @@ -179,18 +174,27 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); const dbExec: string[] = []; + // Go's default `[db] shadow_port` (`legacy-db-config.toml-read.ts`'s + // `DEFAULT_SHADOW_PORT`) — none of these tests override it. The migrations- + // catalog resolution's shadow (CLI-1956) now ALSO connects through this same + // fake `LegacyDbConnection` for its own platform-baseline setup/migration + // replay, so its SQL (BEGIN/REVOKE.../CREATE DATABASE contrib_regression) must + // be excluded from `dbExec`, which every "not yet applied" assertion below + // expects to stay empty until the REAL local-apply connection + // (`applyMigrationToLocal`, `toml.port`) runs. + const SHADOW_PORT = 54320; const dbConn = Layer.succeed(LegacyDbConnection, { - connect: () => + connect: (cfg: LegacyPgConnInput) => Effect.succeed({ exec: (sql: string) => opts.applyFails === true && sql.startsWith("ALTER") ? Effect.fail({ _tag: "LegacyDbExecError", message: "boom" } as never) : Effect.sync(() => { - dbExec.push(sql); + if (cfg.port !== SHADOW_PORT) dbExec.push(sql); }), query: (sql: string) => Effect.sync(() => { - dbExec.push(sql); + if (cfg.port !== SHADOW_PORT) dbExec.push(sql); return []; }), extensionExists: () => Effect.succeed(false), @@ -266,7 +270,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, localPostgresImageChecks, exportCatalogCalls, - provisionShadowCalls, }; } @@ -554,12 +557,16 @@ describe("legacy db schema declarative sync integration", () => { // The warm (first declarative-mode export) fires before the line is printed… const warm = s.exportCatalogCalls.find((c) => c.mode === "declarative"); expect(warm?.rawChunksAt).toBeLessThanOrEqual(lineAt); - // …and the diff's migrations-catalog resolution (now native, CLI-1959 — - // provisions its shadow via `provisionShadow` instead of a seam `exportCatalog` - // call) fires after it, so the line sits at the end of the bootstrap, matching - // Go's ordering. - const diffStart = s.provisionShadowCalls.find((c) => c.mode === "diff" && !c.targetLocal); - expect(diffStart?.rawChunksAt).toBeGreaterThan(lineAt); + // …and the diff's migrations-catalog resolution (native, CLI-1959 cache + // mechanics + CLI-1956 native shadow provisioning — no seam `exportCatalog` + // call for it at all) fires after it, so the line sits at the end of the + // bootstrap, matching Go's ordering. `legacyGetMigrationsCatalogRef` prints + // "Creating shadow database..." right before provisioning; use that line's + // own position as the "diff's shadow started" signal. + const diffStartIndex = s.out.rawChunks.findIndex( + (c) => c.stream === "stderr" && stripAnsi(c.text) === "Creating shadow database...\n", + ); + expect(diffStartIndex).toBeGreaterThan(lineAt); // The generated files actually landed in the printed (resolved) dir. expect( existsSync( diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts new file mode 100644 index 0000000000..ae6075ab2e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts @@ -0,0 +1,915 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDebugFlag } from "../../../../shared/legacy/global-flags.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + type LegacyEdgeRuntimeRunOpts, + type LegacyEdgeRuntimeRunResult, + LegacyEdgeRuntimeScript, +} from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; +import { legacyApplyDeclarativePgDelta } from "./legacy-pgdelta.apply.ts"; +import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; + +const CTX: LegacyPgDeltaContext = { + projectId: "ref", + cwd: "/proj", + npmVersion: undefined, + denoVersion: 2, + projectEnv: {}, +}; + +function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: string } = {}) { + const calls: Array = []; + const layer = Layer.succeed(LegacyEdgeRuntimeScript, { + run: (opts: LegacyEdgeRuntimeRunOpts) => { + calls.push(opts); + if (outcome.fail !== undefined) { + return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: outcome.fail })); + } + return Effect.succeed({ + stdout: outcome.stdout ?? "", + stderr: outcome.stderr ?? "", + } satisfies LegacyEdgeRuntimeRunResult); + }, + }); + return { layer, calls }; +} + +function makeDeclarativeDir(): string { + const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-apply-")); + mkdirSync(join(dir, "declarative"), { recursive: true }); + writeFileSync(join(dir, "declarative", "public.sql"), "create table t ();"); + return join(dir, "declarative"); +} + +const failError = (exit: Exit.Exit) => + Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; + +describe("legacyApplyDeclarativePgDelta", () => { + it.effect("fails with LegacyDeclarativeApplyError when the declarative dir doesn't exist", () => { + const edge = fakeEdgeRuntime(); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: "/does/not/exist", + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "declarative schema directory not found", + ); + // Never even reaches the edge-runtime — the exists() check runs first. + expect(edge.calls).toHaveLength(0); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }); + + it.effect("maps an edge-runtime failure to LegacyDeclarativeApplyError", () => { + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ fail: "error running pg-delta script: boom" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toBe( + "error running pg-delta script: boom", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }); + + it.effect("fails with a parse error WITHOUT the raw stdout when --debug is unset", () => { + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "not json{" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + const message = (failError(exit) as { message: string }).message; + expect(message).toContain("failed to parse pg-delta apply output"); + expect(message).not.toContain("stdout:"); + expect(message).not.toContain("not json{"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }); + + it.effect("fails with a parse error INCLUDING the raw stdout when --debug is set", () => { + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "not json{" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + const message = (failError(exit) as { message: string }).message; + expect(message).toContain("failed to parse pg-delta apply output"); + expect(message).toContain("stdout: not json{"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }); + + it.effect( + "fails with a parse error INCLUDING the raw stdout when SUPABASE_DEBUG is set only in the project .env", + () => { + // Go's `Config.Load` -> `loadNestedEnv` `os.Setenv`s the project `supabase/.env` into the + // process before `pgdelta.ApplyDeclarative` ever reads `viper.GetBool("DEBUG")` + // (review: PRRT_kwDOErm0O86XL_oz) — so a `SUPABASE_DEBUG` set only in `supabase/.env`, + // never in the shell or via `--debug`, still surfaces the raw stdout. Delete any shell + // `SUPABASE_DEBUG` first: shell *presence* (even `false`) would otherwise suppress the + // project value entirely, per `legacyViperEnvBoolWithProjectFallback`'s own semantics. + const previous = process.env["SUPABASE_DEBUG"]; + delete process.env["SUPABASE_DEBUG"]; + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "not json{" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta( + { ...CTX, projectEnv: { SUPABASE_DEBUG: "true" } }, + { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }, + ).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + const message = (failError(exit) as { message: string }).message; + expect(message).toContain("failed to parse pg-delta apply output"); + expect(message).toContain("stdout: not json{"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_DEBUG"]; + else process.env["SUPABASE_DEBUG"] = previous; + }), + ), + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with a normal status-failure summary (not a parse error) when stdout is a top-level JSON null", + () => { + // Go's `json.Unmarshal([]byte("null"), &result)` into the zero-valued (non-pointer) + // `ApplyResult` struct is a no-op that returns no error (verified empirically) — Go falls + // through to the normal `result.Status != "success"` branch and prints the usual + // failed-apply summary with every counter at its zero value, rather than treating `null` + // as a parse failure. `legacyApplyDeclarativePgDelta` must normalize `null` to `{}` before + // its own structural guard, matching that behavior (review: PRRT_kwDOErm0O86W8ZYo). + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "null" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toBe( + "pg-delta declarative apply failed with status: ", + ); + expect((failError(exit) as { message: string }).message).not.toContain( + "failed to parse pg-delta apply output", + ); + expect(out.stderrText).toContain('pg-delta apply returned status "".'); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyDeclarativeApplyError (not an unhandled defect) when stdout is syntactically valid but non-object, non-null JSON", + () => { + // Unlike `null` (see the sibling test above), Go's `json.Unmarshal` genuinely rejects an + // array/string/number/bool payload for a struct destination with an UnmarshalTypeError — + // so a bare `JSON.parse(...) as LegacyPgDeltaApplyResult` cast would let `parsed.status` + // throw an unhandled TypeError instead of failing typed. + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "42" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect("fails with LegacyDeclarativeApplyError when stdout is a JSON array", () => { + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: "[1,2,3]" }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }); + + it.effect( + "fails with LegacyDeclarativeApplyError (not an unhandled defect) when a field typed as an array arrives as an object", + () => { + // A configured or future pg-delta emitting `{"status":"error","errors":{"length":1}}` must + // not reach `legacyFormatApplyFailure`'s `for (const issue of errors)`, which would throw an + // unhandled TypeError on a non-iterable object — Go's `json.Unmarshal` rejects this the same + // way, since `Errors` is declared `[]ApplyIssue` (`apps/cli-go/internal/pgdelta/apply.go:33`). + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "error", errors: { length: 1 } }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyDeclarativeApplyError (not treated as a false success) when an errors array element is a number", + () => { + // A configured or future pg-delta emitting `{"status":"success","errors":[123]}` must not + // be accepted as a successful apply. Verified against Go's real `ApplyIssue.UnmarshalJSON` + // (`apps/cli-go/internal/pgdelta/apply.go:124-142`): a numeric element fails BOTH its + // string-arm and its object-arm unmarshal, which fails the WHOLE `ApplyResult` decode — + // Go never reaches a "success" status in this case, so the TS guard must reject it too. + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "success", errors: [123] }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyDeclarativeApplyError (not treated as a false success) when a diagnostics array element is a bare string", + () => { + // Unlike `ApplyIssue`, Go's `ApplyDiagnosis.UnmarshalJSON` (`apply.go:79-116`) has no + // bare-string acceptance branch, so `{"diagnostics":["boom"]}` fails Go's whole decode too + // (verified: unmarshaling a JSON string into `ApplyDiagnosis`'s shadow struct errors). + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "success", diagnostics: ["boom"] }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts a diagnostics element whose statementId is a mistyped, non-object/non-string value (Go degrades it silently)", + () => { + // Unlike a top-level array-element shape mismatch, Go's `ApplyDiagnosis.UnmarshalJSON` + // decodes `statementId` into a `json.RawMessage` first (accepts ANY valid JSON value), then + // tries `ApplyStatementLocation`, then a bare string, and silently leaves `StatementID` nil + // if BOTH fail — never propagating an error. A mistyped `statementId` must NOT fail the + // whole parse. + const dir = makeDeclarativeDir(); + const payload = { + status: "success", + diagnostics: [{ message: "note", statementId: 42 }], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "drops a diagnostics element's statementId when a nested field is mistyped, instead of rendering a bogus location (Go's nil fallback)", + () => { + // Unlike the mistyped-non-object/non-string `statementId` case above, this reproduces a + // mistyped FIELD INSIDE an otherwise object-shaped `statementId` + // (`{"filePath":123,...}`). Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:100-115`) + // tries the `ApplyStatementLocation` object shape first — the mistyped `filePath` fails + // that decode — then falls back to a bare string, which ALSO fails (it's an object, not a + // string) — so Go silently leaves `StatementID` nil rather than erroring the whole parse, + // verified empirically. Rendering the raw object anyway would show a bogus `(123#1)` + // location Go never emits. + const dir = makeDeclarativeDir(); + const payload = { + status: "success", + diagnostics: [{ message: "note", statementId: { filePath: 123, statementIndex: 1 } }], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts a null scalar field on an errors/diagnostics element and formats it as absent (Go's encoding/json leaves the zero value)", + () => { + // `ApplyIssue`'s non-`Statement` fields (`Code`/`Message`/`IsDependencyError`/`Position`/ + // `Detail`/`Hint`) and `ApplyDiagnosis`'s (`Code`/`Message`/`SuggestedFix`) are all plain, + // non-pointer Go types decoded via the default `encoding/json` — verified empirically that + // a JSON `null` for a non-pointer struct field produces NO error and leaves the zero value, + // so `{"errors":[{"message":null}]}` is a valid, Go-accepted payload, not a parse failure. + // The formatter's existing `String(issue.message ?? "")` already renders a zero-value + // message as "unknown pg-delta issue" once the guard lets the `null` through. + const dir = makeDeclarativeDir(); + const payload = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [{ message: null, code: null, isDependencyError: null, position: null }], + diagnostics: [{ message: null, code: null, suggestedFix: null }], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect(out.stderrText).toContain("- unknown pg-delta issue"); + expect(out.stderrText).toContain("- unknown pg-delta diagnostic"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts a null top-level counter and formats it as zero (Go's encoding/json leaves the zero value)", + () => { + // `ApplyResult` has no custom `UnmarshalJSON` of its own, so its plain, non-pointer `int` + // counters (`TotalStatements`/`TotalRounds`/`TotalApplied`/`TotalSkipped`) decode via the + // default `encoding/json` — verified empirically that a JSON `null` for a non-pointer `int` + // field produces NO error and leaves the zero value, so + // `{"status":"success","totalApplied":null}` is a valid, Go-accepted payload, not a parse + // failure — same "null means absent" rule already applied to nested issue/diagnostic + // scalar fields above. + const dir = makeDeclarativeDir(); + const payload = { status: "success", totalApplied: null, totalRounds: null }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stderrText).toContain("Applied 0 statements in 0 round(s)."); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts an absent or null top-level status and formats it as the empty-string zero value (Go's encoding/json)", + () => { + // `ApplyResult.Status` has no custom `UnmarshalJSON` of its own, so it's a plain, + // non-pointer `string` field decoded via the default `encoding/json` — verified + // empirically that `{}` and `{"status":null}` both decode with `err == nil` and + // `Status == ""`, reaching the normal failed-apply summary (not a parse failure). + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ stdout: JSON.stringify({}) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toBe( + "pg-delta declarative apply failed with status: ", + ); + expect(out.stderrText).toContain('pg-delta apply returned status "".'); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "accepts a null errors/stuckStatements/validationErrors/diagnostics array and treats it as empty (Go's encoding/json leaves a nil slice)", + () => { + // `ApplyResult`'s array fields have no custom `UnmarshalJSON` of their own, so Go's + // `encoding/json` accepts a JSON `null` for a `[]T` slice field with no error, leaving a + // nil (zero-length) slice — verified empirically: + // `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` + // with `len(r.Errors) == 0`. A payload reporting all four as `null` must format as if none + // were reported at all, not fail the parse. + const dir = makeDeclarativeDir(); + const payload = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: null, + stuckStatements: null, + validationErrors: null, + diagnostics: null, + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect(out.stderrText).toContain("No per-statement diagnostics were reported by pg-delta."); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyDeclarativeApplyError (not an unhandled defect) when a field typed as a number arrives as a string", + () => { + // Same reasoning as the array-typed-field test above, for `ApplyResult`'s numeric fields + // (`TotalApplied int`, etc.) — a malformed counter must fail the parse, not be silently + // treated as a genuine successful-apply summary. + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "success", totalApplied: "5" }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "fails with LegacyDeclarativeApplyError (not an unhandled defect) when a field typed as an int arrives as a fractional number", + () => { + // Go's `TotalApplied int` (and its `int`-typed siblings) reject any JSON number literal + // with a decimal point via `strconv.ParseInt` on the raw literal text — verified + // empirically that `json.Unmarshal` on `{"totalApplied":1.5}` errors identically to a + // string-typed field mismatch, so `1.5` must fail the parse here too, not be treated as a + // truncated/rounded successful-apply count. + const dir = makeDeclarativeDir(); + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ status: "success", totalApplied: 1.5 }), + }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output", + ); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "on a non-success status, prints the formatted failure to stderr but not the raw payload when --debug is unset", + () => { + const dir = makeDeclarativeDir(); + const payload = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: ["boom"], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeApplyError"); + expect((failError(exit) as { message: string }).message).toBe( + "pg-delta declarative apply failed with status: error", + ); + expect(out.stderrText).toContain('pg-delta apply returned status "error".'); + expect(out.stderrText).toContain("- boom"); + expect(out.stderrText).not.toContain("pg-delta apply result:"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "on a non-success status with --debug set, additionally dumps the pretty-printed raw payload", + () => { + const dir = makeDeclarativeDir(); + const payload = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: ["boom"], + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }).pipe(Effect.exit); + expect(out.stderrText).toContain("pg-delta apply result:"); + expect(out.stderrText).toContain(JSON.stringify(payload, null, 2)); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); + + it.effect( + "on success, prints the applied-statements summary and forwards SCHEMA_PATH/TARGET/binds", + () => { + const dir = makeDeclarativeDir(); + const payload = { + status: "success", + totalStatements: 3, + totalApplied: 3, + totalRounds: 2, + totalSkipped: 0, + }; + const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); + const out = mockOutput(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* legacyApplyDeclarativePgDelta(CTX, { + fs, + declarativeDirAbs: dir, + target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + }); + expect(out.stderrText).toContain("Applying declarative schemas via pg-delta..."); + expect(out.stderrText).toContain("Applied 3 statements in 2 round(s)."); + const opts = edge.calls[0]!; + expect(opts.env["SCHEMA_PATH"]).toBe("/declarative"); + expect(opts.env["TARGET"]).toBe( + "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", + ); + expect(opts.binds).toEqual([ + "supabase_edge_runtime_ref:/root/.cache/deno:rw", + `${dir}:/declarative:ro`, + ]); + expect(opts.errPrefix).toBe("error running pg-delta script"); + rmSync(dir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + edge.layer, + out.layer, + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(CliArgs, { args: [] }), + ), + ), + ); + }, + ); +}); 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 new file mode 100644 index 0000000000..d478c72ae0 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts @@ -0,0 +1,922 @@ +/** + * Port of Go's `pgdelta.ApplyDeclarative` (`apps/cli-go/internal/pgdelta/apply.go:299-360`) — + * CLI-1956's declarative-apply runner: applies `supabase/database` (or the configured + * declarative dir) to the shadow's `contrib_regression` override database via pg-delta's + * declarative apply engine, run inside the edge-runtime container. + * + * This is genuinely NEW work, not a seam removal: the Deno script template itself + * (`legacyPgDeltaDeclarativeApplyScript`) already existed (ported for a different, now-dead + * seam), but nothing in TS ever invoked it — every declarative apply ran through the bundled + * Go binary until now. + */ + +import { Data, Effect, type FileSystem } from "effect"; + +import { legacyResolveDebugWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { + legacyInterpolatePgDeltaScript, + legacyPgDeltaDeclarativeApplyScript, +} from "./legacy-pgdelta.deno-templates.ts"; +import { + legacyEdgeRuntimeId, + legacyPgDeltaNpmRegistryOption, + type LegacyPgDeltaContext, +} from "../../../shared/legacy-pgdelta.ts"; + +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + +/** `pgdelta.ApplyDeclarative` failed — Go's own error messages at each step (see call sites below). */ +export class LegacyDeclarativeApplyError extends Data.TaggedError("LegacyDeclarativeApplyError")<{ + readonly message: string; +}> {} + +/** Go's `containerSchemaPath` (`apply.go:311`). */ +const LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH = "/declarative"; + +/** One statement/error entry — Go's `ApplyIssue`, which may arrive as a bare string or an object. */ +export interface LegacyPgDeltaApplyIssue { + readonly statement?: { + // Optional (not required): `legacyIsValidApplyIssueElement` only checks the TYPE of each + // present field (matching Go's per-field `json.Unmarshal` type check), not that every + // field is present — so a partially-populated `statement` object (e.g. a future pg-delta + // release that only reports `id`) must still render, not throw — see + // `legacyFormatApplyIssue`'s defensive `?? ""` handling below. Go's own `(i + // *ApplyIssue) UnmarshalJSON` is deliberately just as permissive about ABSENT fields, + // while still rejecting a MISTYPED one for the whole payload — see + // `legacyIsValidApplyIssueElement`'s own doc comment. + // + // `| null` on each of `id`/`sql`/`statementClass` (not just `?`): these are plain, + // non-pointer `string` fields on Go's `ApplyStatement`, which has no custom + // `UnmarshalJSON` of its own — so they decode via the default `encoding/json`, which + // (verified empirically) accepts a JSON `null` for a non-pointer field with NO error and + // leaves the zero value (`""`), the same "null means absent" rule as every other scalar + // on this interface — see {@link LegacyPgDeltaApplyIssue.code}'s doc comment. + readonly id?: string | null; + readonly sql?: string | null; + readonly statementClass?: string | null; + // `| null` (not just `?`): Go's `Statement *ApplyStatement` is a pointer, so a JSON + // `"statement":null` entry (e.g. `{"statement":null,"message":"failed"}`) unmarshals to a + // nil pointer — `formatApplyIssue`'s `issue.Statement == nil` (`apply.go:202`) treats that + // identically to a missing field. `legacyFormatApplyIssue`'s guard below must check for + // `null` as well as `undefined`, or a `JSON.parse`'d `null` reaches `issue.statement.*` and + // throws a `TypeError` instead of rendering the message. + } | null; + // `| null` on every scalar below (not just `?`): `ApplyIssue`'s non-`Statement` fields + // (`Code`/`Message`/`IsDependencyError`/`Position`/`Detail`/`Hint`) are all plain, + // non-pointer Go types (`string`/`bool`/`int`) decoded via the default `encoding/json` + // inside `(i *ApplyIssue) UnmarshalJSON`'s `json.Unmarshal(trimmed, &parsed)` call + // (`apply.go:133-138`) — verified empirically that unmarshaling a JSON `null` into a + // non-pointer struct field produces NO error and leaves the zero value untouched (Go's + // documented "null means absent" rule applies to any Go type, not just pointers/maps/ + // slices/interfaces). So `{"message":null}` is a valid, Go-accepted `ApplyIssue` element — + // rejecting it here would turn an otherwise-parseable pg-delta payload into a spurious + // "failed to parse pg-delta apply output" instead of rendering `unknown pg-delta issue` + // the way `legacyFormatApplyIssueMessage`'s existing `String(issue.message ?? "")` already + // does once this type (and `legacyIsValidApplyIssueElement`) let a null through. + readonly code?: string | null; + readonly message?: string | null; + readonly isDependencyError?: boolean | null; + readonly position?: number | null; + readonly detail?: string | null; + readonly hint?: string | null; +} + +/** + * Go's `ApplyStatementLocation` (pg-topo's `StatementId` shape). `ApplyStatementLocation` + * has no custom `UnmarshalJSON` of its own, so `filePath`/`statementIndex`/`sourceOffset` + * are plain, non-pointer Go types decoded via the default `encoding/json` — same "null + * means absent" rule as every other scalar in this file (verified empirically, see {@link + * LegacyPgDeltaApplyIssue.code}'s doc comment), hence `| null` on all three. `sourceOffset` + * is never read by {@link legacyFormatStatementLocation} (Go's own `formatStatementLocation` + * doesn't display it either), but it still must be validated in + * {@link legacyNormalizeApplyStatementId}: Go's struct-level `json.Unmarshal` fails the + * WHOLE object the moment any declared field — including this unused one — has the wrong + * type, not just the fields the formatter happens to read. + */ +export interface LegacyPgDeltaApplyStatementLocation { + readonly filePath?: string | null; + readonly statementIndex?: number | null; + readonly sourceOffset?: number | null; +} + +/** Go's `ApplyDiagnosis` — a pg-topo static-analysis diagnostic. */ +export interface LegacyPgDeltaApplyDiagnosis { + // `| null` on `code`/`message`/`suggestedFix` (not just `?`): `(d *ApplyDiagnosis) + // UnmarshalJSON`'s shadow `raw` struct (`apply.go:87-92`) declares these as plain, + // non-pointer `string` fields with no custom unmarshaler of their own, so — same + // empirically-verified "null means absent" `encoding/json` rule as + // {@link LegacyPgDeltaApplyIssue.code} — a JSON `null` for any of them decodes with no + // error and leaves `""`, not a rejected payload. + readonly code?: string | null; + readonly message?: string | null; + // `| null` (not just `?`): Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) + // explicitly maps a JSON `"statementId":null` to a nil `*ApplyStatementLocation`, and + // `formatStatementLocation` (`apply.go:263-274`) returns `""` for a nil pointer — so the TS + // path must accept `null` here as absent too, or a `JSON.parse`'d `null` reaches + // `legacyFormatStatementLocation`'s `resolved.filePath` and throws a `TypeError` instead of + // rendering the rest of the diagnostic. + readonly statementId?: LegacyPgDeltaApplyStatementLocation | string | null; + readonly suggestedFix?: string | null; +} + +/** + * The JSON payload `pgdelta_declarative_apply.ts` prints on stdout. Go's `ApplyResult`. + * + * `| null` on each `total*` counter (not just `?`): `ApplyResult` has no custom + * `UnmarshalJSON` of its own, so these plain, non-pointer `int` fields decode via the + * default `encoding/json`, which — verified empirically, same rule as {@link + * LegacyPgDeltaApplyIssue.code} — accepts a JSON `null` for a non-pointer `int` field with + * NO error and leaves the zero value. So `{"status":"success","totalApplied":null}` is a + * valid, Go-accepted `ApplyResult`, not a parse failure. + * + * `| null` on each array field too (`errors`/`stuckStatements`/`validationErrors`/ + * `diagnostics`): these are plain, non-pointer Go `[]T` slice fields with no custom + * unmarshaler on `ApplyResult` itself, and `encoding/json` accepts a JSON `null` for a + * slice field with NO error, leaving a nil (zero-length) slice — verified empirically: + * `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` + * with `r.Errors == nil` (`len(r.Errors) == 0`). `formatApplyFailure`'s `len(result.Errors) + * > 0` guards treat a nil slice identically to an empty one, so `{"status":"error", + * "errors":null}` must be accepted here too, not rejected as a parse failure. + * + * `status?: string | null` (not required non-null `string`): like every other field here, + * `Status` has no custom unmarshaler on `ApplyResult` itself, so an absent key or a JSON + * `null` decodes with NO error and leaves Go's zero value `""` — verified empirically: + * `json.Unmarshal([]byte(\`{}\`), &r)` and the `{"status":null}` variant both return + * `err == nil` with `r.Status == ""`. So `{}`/`{"status":null}` must reach the normal + * failed-apply summary (status rendered as `""`), not a rejected parse failure. + */ +export interface LegacyPgDeltaApplyResult { + readonly status?: string | null; + readonly totalStatements?: number | null; + readonly totalRounds?: number | null; + readonly totalApplied?: number | null; + readonly totalSkipped?: number | null; + readonly errors?: ReadonlyArray | null; + readonly stuckStatements?: ReadonlyArray | null; + readonly validationErrors?: ReadonlyArray | null; + readonly diagnostics?: ReadonlyArray | null; +} + +/** + * Go's `int`-typed fields (`TotalStatements`/`TotalRounds`/`TotalApplied`/`TotalSkipped` on + * `ApplyResult`, `Position` on `ApplyIssue`) reject any JSON number literal containing a decimal + * point or exponent — Go's `json.Unmarshal` parses the literal text via `strconv.ParseInt` + * rather than decoding a `float64` and truncating it, so even a "whole" float like `1.0` fails + * identically to `1.5` (verified empirically: `json.Unmarshal([]byte(\`{"totalApplied":1.0}\`), + * &r)` and the `1.5` variant both return `cannot unmarshal number ... into ... type int`). A + * `JSON.parse`'d `1.0` is already indistinguishable from the integer `1` by the time it reaches + * this guard — `JSON.parse` itself collapses that distinction, so that exact literal-text + * sub-case can't be reproduced post-parse — but `Number.isInteger` still correctly rejects any + * genuinely fractional value like `1.5`, which is the reachable and observable part of this + * parity gap. + */ +function legacyIsGoIntNumber(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value); +} + +/** + * Go's `(i *ApplyIssue) UnmarshalJSON` (`apply.go:124-142`) accepts `null`, a bare string, or + * an object whose PRESENT fields each match `ApplyIssue`'s declared JSON types — anything else + * (a number, boolean, array, or an object with a mistyped field) fails Go's `json.Unmarshal` + * for the WHOLE `ApplyResult`, not just that element. Verified empirically against Go's real + * struct definitions: `{"errors":[123]}` returns `cannot unmarshal number into Go struct field + * ApplyResult.errors of type main.alias`, and `{"errors":[{"message":123}]}` returns `cannot + * unmarshal number into Go struct field ApplyResult.errors.message of type string` — both abort + * the ENTIRE parse rather than degrading that one element, so a payload like + * `{"status":"success","errors":[123]}` must be rejected here too, not accepted as a (false) + * success. Nested `statement` is checked the same way, one level deep — Go's `ApplyStatement` + * has no custom `UnmarshalJSON`, so a mistyped `id`/`sql`/`statementClass` fails identically. + * + * A JSON `null` for any INDIVIDUAL scalar field, though — top-level (`code`/`message`/ + * `isDependencyError`/`position`/`detail`/`hint`) or nested under `statement` + * (`id`/`sql`/`statementClass`) — is NOT a mistyped field: every one of these is a plain, + * non-pointer Go type with no custom unmarshaler, and `encoding/json` accepts `null` for those + * with no error, leaving the zero value (verified empirically — see + * {@link LegacyPgDeltaApplyIssue.code}'s doc comment). So `null` is tolerated alongside each + * field's declared type below, matching Go exactly instead of rejecting an otherwise + * Go-compatible payload like `{"message":null}`. + */ +function legacyIsValidApplyIssueElement(value: unknown): boolean { + if (value === null || typeof value === "string") return true; + if (typeof value !== "object" || Array.isArray(value)) return false; + if ("statement" in value) { + const statement = value.statement; + if (statement !== null && statement !== undefined) { + if (typeof statement !== "object" || Array.isArray(statement)) return false; + if ("id" in statement && statement.id !== null && typeof statement.id !== "string") { + return false; + } + if ("sql" in statement && statement.sql !== null && typeof statement.sql !== "string") { + return false; + } + if ( + "statementClass" in statement && + statement.statementClass !== null && + typeof statement.statementClass !== "string" + ) { + return false; + } + } + } + if ("code" in value && value.code !== null && typeof value.code !== "string") return false; + if ("message" in value && value.message !== null && typeof value.message !== "string") { + return false; + } + if ( + "isDependencyError" in value && + value.isDependencyError !== null && + typeof value.isDependencyError !== "boolean" + ) { + return false; + } + if ("position" in value && value.position !== null && !legacyIsGoIntNumber(value.position)) { + return false; + } + if ("detail" in value && value.detail !== null && typeof value.detail !== "string") return false; + if ("hint" in value && value.hint !== null && typeof value.hint !== "string") return false; + return true; +} + +/** + * Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-116`) — unlike `ApplyIssue`, there is + * NO bare-string acceptance branch, so only `null` or an object is valid; a bare + * string/number/boolean/array element fails the whole `ApplyResult` unmarshal. Verified + * empirically: `{"diagnostics":["boom"]}` returns `cannot unmarshal string into Go struct field + * ApplyResult.diagnostics of type struct {...}`. `statementId` is deliberately NOT type-checked + * here: Go decodes it into a `json.RawMessage` first (accepts any valid JSON value), then tries + * `ApplyStatementLocation`, then a bare string, and silently leaves `StatementID` nil if BOTH + * fail — it never propagates an error for a mistyped `statementId` (verified empirically: + * `{"statementId":42}` and `{"statementId":{"filePath":123}}` both unmarshal with `err: `), + * so `legacyNormalizeApplyDiagnosis`/`legacyFormatStatementLocation`'s existing defensive + * handling is the correct (and only) place that degrades gracefully. + * + * Same "null tolerated on a scalar field" rule as {@link legacyIsValidApplyIssueElement} + * applies to `code`/`message`/`suggestedFix` here too: `UnmarshalJSON`'s shadow `raw` struct + * (`apply.go:87-92`) decodes them via the default `encoding/json`, which accepts a JSON + * `null` for a plain `string` field with no error (verified empirically). + */ +function legacyIsValidApplyDiagnosisElement(value: unknown): boolean { + if (value === null) return true; + if (typeof value !== "object" || Array.isArray(value)) return false; + if ("code" in value && value.code !== null && typeof value.code !== "string") return false; + if ("message" in value && value.message !== null && typeof value.message !== "string") { + return false; + } + if ( + "suggestedFix" in value && + value.suggestedFix !== null && + typeof value.suggestedFix !== "string" + ) { + return false; + } + return true; +} + +/** + * Structural guard for Go's `ApplyResult` JSON shape, applied to an untrusted + * `JSON.parse` of the pg-delta subprocess's stdout. A syntactically valid but non-object + * payload — an array, a bare string/number/bool (e.g. a future pg-delta release that + * changes its output shape) — must fail typed as {@link LegacyDeclarativeApplyError}, not + * crash `parsed.status` with an unhandled `TypeError`. A top-level `null` is NOT one of + * these: `json.Unmarshal([]byte("null"), &result)` into Go's zero-valued (non-pointer) + * `ApplyResult` struct is a no-op that returns no error (verified empirically), unlike the + * array/string/number/bool cases, which genuinely fail with an `UnmarshalTypeError` — so the + * caller normalizes a top-level `null` to `{}` before this guard ever sees it (review: + * PRRT_kwDOErm0O86W8ZYo), and this function only needs to reject the cases Go actually + * rejects. + * + * Every field `ApplyResult` itself declares a type for is checked when present — Go's + * `json.Unmarshal` rejects the whole payload with an `UnmarshalTypeError` the moment any of + * these doesn't match its struct field's declared type (`Errors []ApplyIssue`, `TotalApplied + * int`, etc., `apps/cli-go/internal/pgdelta/apply.go:27-44`), so e.g. an `errors` field that + * arrives as an object (`{"length":1}`) instead of an array must fail here too, not reach + * `legacyFormatApplyFailure`'s `for (const issue of errors)` and throw an unhandled + * `TypeError` defect. Each ARRAY field's elements are also validated ({@link + * legacyIsValidApplyIssueElement}/{@link legacyIsValidApplyDiagnosisElement}) since Go's own + * per-element `UnmarshalJSON` implementations reject a malformed element by failing the WHOLE + * `ApplyResult` decode, not by skipping just that element — see those functions' own doc + * comments for the empirical verification. This is also the AGENTS.md-mandated way to narrow + * `unknown` without an `as` cast. + * + * Each array field also tolerates a JSON `null` (not just an absent key): `ApplyResult`'s + * `[]ApplyIssue`/`[]ApplyDiagnosis` fields have no custom unmarshaler of their own, and + * Go's `encoding/json` accepts `null` for a slice field with no error, leaving a nil + * (zero-length) slice — verified empirically, see {@link LegacyPgDeltaApplyResult}'s own + * doc comment. So `{"status":"error","errors":null}` is a valid, Go-accepted payload, not + * a rejected one. + * + * `status` is checked the same "null/absent tolerated" way as every other field, NOT + * required to be present and non-null: an absent key or `"status":null` is Go's zero + * value `""`, not a parse failure — see {@link LegacyPgDeltaApplyResult}'s own doc comment + * for the empirical verification. + */ +function legacyIsPgDeltaApplyResult(value: unknown): value is LegacyPgDeltaApplyResult { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + ("status" in value && value.status !== null && typeof value.status !== "string") + ) { + return false; + } + if ( + "totalStatements" in value && + value.totalStatements !== null && + !legacyIsGoIntNumber(value.totalStatements) + ) { + return false; + } + if ( + "totalRounds" in value && + value.totalRounds !== null && + !legacyIsGoIntNumber(value.totalRounds) + ) { + return false; + } + if ( + "totalApplied" in value && + value.totalApplied !== null && + !legacyIsGoIntNumber(value.totalApplied) + ) { + return false; + } + if ( + "totalSkipped" in value && + value.totalSkipped !== null && + !legacyIsGoIntNumber(value.totalSkipped) + ) { + return false; + } + if ("errors" in value && value.errors !== null) { + if (!Array.isArray(value.errors) || !value.errors.every(legacyIsValidApplyIssueElement)) { + return false; + } + } + if ("stuckStatements" in value && value.stuckStatements !== null) { + if ( + !Array.isArray(value.stuckStatements) || + !value.stuckStatements.every(legacyIsValidApplyIssueElement) + ) { + return false; + } + } + if ("validationErrors" in value && value.validationErrors !== null) { + if ( + !Array.isArray(value.validationErrors) || + !value.validationErrors.every(legacyIsValidApplyIssueElement) + ) { + return false; + } + } + if ("diagnostics" in value && value.diagnostics !== null) { + if ( + !Array.isArray(value.diagnostics) || + !value.diagnostics.every(legacyIsValidApplyDiagnosisElement) + ) { + return false; + } + } + return true; +} + +/** Go's `(i *ApplyIssue) UnmarshalJSON` string/object dual shape, applied post-`JSON.parse`. */ +function legacyNormalizeApplyIssue( + raw: LegacyPgDeltaApplyIssue | string | null | undefined, +): LegacyPgDeltaApplyIssue { + if (raw === null || raw === undefined) return {}; + if (typeof raw === "string") return { message: raw }; + return raw; +} + +/** + * Go's `(d *ApplyDiagnosis) UnmarshalJSON` three-way `statementId` fallback + * (`apply.go:100-115`): decode into `ApplyStatementLocation` first — an object whose + * PRESENT `filePath`/`statementIndex` fields each match the declared type (`null` + * tolerated per field, same rule as {@link legacyIsValidApplyIssueElement}) — and if + * that fails (a non-object, or an object with a mistyped field), fall back to a bare + * string; if BOTH fail, Go silently leaves `StatementID` nil rather than erroring the + * whole `ApplyResult` parse. Verified empirically: `{"statementId":{"filePath":123, + * "statementIndex":1}}` decodes with `StatementID == nil` in Go — the object-shape + * unmarshal fails on the mistyped `filePath`, and the string fallback also fails since + * the value is an object, not a string. `legacyIsValidApplyDiagnosisElement` deliberately + * does NOT check `statementId`'s shape (see its own doc comment — Go defers this into a + * `json.RawMessage` that never fails the outer parse), so this is the only place that can + * drop a malformed location instead of `legacyFormatStatementLocation`'s `String(...)` + * coercion rendering a bogus location (e.g. `123#1`) Go would never have shown. + * + * `sourceOffset` is validated here too, even though {@link legacyFormatStatementLocation} + * never reads it: Go's struct-level unmarshal (`apply.go:105`) fails on ANY declared field + * with the wrong type, not just the ones a later formatter happens to display. Verified + * empirically: `json.Unmarshal([]byte(\`{"filePath":"x.sql","sourceOffset":"bad"}\`), &loc)` + * returns a non-nil `UnmarshalTypeError` even though `filePath` itself is well-typed, so + * the object-shape decode fails, the string fallback also fails (the value is an object), + * and Go leaves `StatementID` nil — dropping the location entirely rather than keeping a + * `{filePath:"x.sql"}` that misattributes the diagnostic to the wrong file. + */ +function legacyNormalizeApplyStatementId( + raw: LegacyPgDeltaApplyStatementLocation | string | null | undefined, +): LegacyPgDeltaApplyStatementLocation | undefined { + if (raw === null || raw === undefined) return undefined; + if (typeof raw === "string") return { filePath: raw }; + if (typeof raw !== "object" || Array.isArray(raw)) return undefined; + const filePathOk = + !("filePath" in raw) || raw.filePath === null || typeof raw.filePath === "string"; + const indexOk = + !("statementIndex" in raw) || + raw.statementIndex === null || + legacyIsGoIntNumber(raw.statementIndex); + const sourceOffsetOk = + !("sourceOffset" in raw) || raw.sourceOffset === null || legacyIsGoIntNumber(raw.sourceOffset); + if (filePathOk && indexOk && sourceOffsetOk) return raw; + return undefined; +} + +/** Go's `(d *ApplyDiagnosis) UnmarshalJSON` defensive `statementId` handling. */ +function legacyNormalizeApplyDiagnosis( + raw: LegacyPgDeltaApplyDiagnosis | null | undefined, +): LegacyPgDeltaApplyDiagnosis { + if (raw === null || raw === undefined) return {}; + return { ...raw, statementId: legacyNormalizeApplyStatementId(raw.statementId) }; +} + +/** + * Go's `formatStatementLocation` (`apply.go:262-275`). `String(... ?? "")` rather than a bare + * `?? ""` before `.trim()`: `filePath` is typed as `string | undefined`, but this whole module + * types an untrusted `JSON.parse` of subprocess output, so a malformed payload can hand this a + * non-string value (e.g. a number) at runtime — `?? ""` alone only substitutes `null`/ + * `undefined`, so a non-string, non-nullish value would still reach `.trim()` and throw. The + * `resolved === null` check (not just `undefined`) is the same shape: Go's `StatementID + * *ApplyStatementLocation` is a pointer, so `"statementId":null` unmarshals to `nil` and + * `formatStatementLocation`'s own `loc == nil` (`apply.go:264`) treats it as absent — checking + * only `undefined` here would fall through to `resolved.filePath` on a `null` and throw a + * `TypeError` instead of rendering the rest of the diagnostic. + */ +function legacyFormatStatementLocation( + loc: LegacyPgDeltaApplyStatementLocation | string | null | undefined, +): string { + const resolved = typeof loc === "string" ? { filePath: loc } : loc; + if (resolved === null || resolved === undefined) return ""; + const path = String(resolved.filePath ?? "").trim(); + if (path.length === 0) return ""; + if ((resolved.statementIndex ?? 0) > 0) return `${path}#${resolved.statementIndex}`; + return path; +} + +/** + * Go's `formatStatementSQL` (`apply.go:277-283`): collapse whitespace, then truncate at 120 + * UTF-8 bytes — not JS UTF-16 code units. Go's `len(normalized)` and `normalized[:maxLen-3]` + * both count/slice raw bytes, so a statement with multibyte (e.g. non-ASCII identifier) + * characters can be far longer in bytes than in UTF-16 units — a `.length`/`.slice()` guard + * would under-truncate (or not truncate at all) relative to Go's 120-byte limit, changing the + * legacy stderr contract for an already-failed apply. + * + * `\p{White_Space}+`, not `\s+`: `sql` is a user-authored SQL statement pulled verbatim from + * `supabase/declarative`, so — unlike this file's JSON envelope, whose key/shape is controlled + * by the embedded producer script — it can genuinely contain any Unicode code point a user's + * editor wrote, including NEL (code point 0x85) or a BOM (code point 0xFEFF) pasted into a + * comment or string literal. Go's `strings.Fields`/`unicode.IsSpace` and ECMAScript's `\s` + * disagree on both: verified empirically — Go's `unicode.IsSpace(rune(0x85))` (NEL) is `true` + * (`strings.Fields` collapses it, splitting `"a"+NEL+"b"` into two fields) while + * `unicode.IsSpace(rune(0xFEFF))` (BOM) is `false` (`strings.Fields` preserves it inside one + * field); ECMAScript's `\s` is the exact opposite (`/\s/u.test(String.fromCodePoint(0x85))` is + * `false`, `/\s/u.test(String.fromCodePoint(0xfeff))` is `true`). `\p{White_Space}` matches the + * Unicode `White_Space` property Go's `unicode.IsSpace` is itself built from (confirmed + * empirically against the same two code points, plus NBSP `0xA0` and ideographic space + * `0x3000`), so it reproduces Go's classification instead of ECMAScript's — both the rendered + * SQL text and, for a statement long enough to need it, the 120-byte truncation boundary now + * line up with Go's. + * + * Returns a `Buffer`, not a `string`: Go's `[:maxLen-3]` is a raw byte slice with no regard + * for codepoint boundaries, so a multibyte (e.g. non-ASCII identifier) character straddling + * byte 117 is cut mid-sequence, leaving an intentionally INVALID trailing UTF-8 fragment — + * exactly what Go writes to stderr, unvalidated. `Buffer#toString("utf-8")` on that same + * fragment does NOT reproduce it: Node's UTF-8 decoder substitutes U+FFFD for the incomplete + * sequence, and re-encoding that string back to bytes for output yields a DIFFERENT (and + * differently-sized) byte sequence than Go's raw slice — verified empirically: slicing Go's + * own `formatStatementSQL` at a non-boundary-aligned cut produces a 120-byte, deliberately + * invalid-UTF-8 result (`utf8.ValidString` reports `false`), while + * `Buffer.from(sql,"utf-8").subarray(...).toString("utf-8")` on that exact byte range + * decodes+re-encodes to a 121-byte result containing U+FFFD instead. Keeping this a `Buffer` + * all the way to `output.rawBytes` (see {@link legacyFormatApplyFailure}) avoids that + * lossy string round-trip and reproduces Go's bytes exactly, valid or not. + */ +function legacyFormatStatementSql(sql: string): Buffer { + const normalized = sql + .split(/\p{White_Space}+/u) + .filter((part) => part.length > 0) + .join(" "); + const maxLen = 120; + const normalizedBytes = Buffer.from(normalized, "utf-8"); + if (normalizedBytes.byteLength <= maxLen) return normalizedBytes; + return Buffer.concat([normalizedBytes.subarray(0, maxLen - 3), Buffer.from("...", "utf-8")]); +} + +/** + * Joins Buffer "lines" with `\n` — a Buffer-safe equivalent of `Array#join("\n")`, used so + * {@link legacyFormatApplyIssue}/{@link legacyFormatApplyFailure} can embed + * {@link legacyFormatStatementSql}'s raw (possibly invalid-UTF-8) bytes without ever + * decoding them back into a JS string. + */ +function legacyJoinLines(lines: ReadonlyArray): Buffer { + const newline = Buffer.from("\n", "utf-8"); + const parts: Array = []; + lines.forEach((line, index) => { + if (index > 0) parts.push(newline); + parts.push(line); + }); + return Buffer.concat(parts); +} + +/** + * Go's `json.Indent` (`encoding/json/indent.go`): re-flows compact/pretty JSON by inserting + * whitespace between tokens ONLY — every token (string, number, `true`/`false`/`null`) is + * copied byte-for-byte from `src`, never decoded into a value and re-encoded. This is NOT the + * same as `JSON.parse` + `JSON.stringify`: parsing a number decodes it into a JS `float64`, + * which silently loses precision for an integer literal beyond + * `Number.MAX_SAFE_INTEGER` (e.g. a snowflake-style id), and re-stringifying a string + * re-escapes it using `JSON.stringify`'s own rules, which can change an existing escape's + * representation (e.g. `\/` becomes a literal `/`) — both would corrupt the exact debug + * payload users are asked to attach to bug reports. `legacyGoJsonIndentTokens` instead scans + * `src` as a token stream (only tracking string boundaries, via backslash-escape skipping, to + * avoid misreading punctuation inside a string as structural) and reproduces Go's exact + * spacing rules: verified empirically against `encoding/json.Indent` for nested objects/ + * arrays, empty `{}`/`[]` (no inserted newline), a `\/`-escaped string, an emoji (multi-UTF-16 + * code point) string, and an integer literal beyond `Number.MAX_SAFE_INTEGER` — all byte- + * identical to Go's own output. Caller ({@link legacyFormatDebugJson}) is responsible for + * validating `src` is well-formed JSON first; this function assumes it and does not itself + * detect malformed input. + */ +function legacyGoJsonIndentTokens(src: string): string { + let out = ""; + let depth = 0; + let needIndent = false; + let i = 0; + const n = src.length; + const newline = (): void => { + out += `\n${" ".repeat(depth)}`; + }; + const openIndentIfNeeded = (): void => { + if (!needIndent) return; + needIndent = false; + depth++; + newline(); + }; + while (i < n) { + const c = src[i]; + if (c === " " || c === "\t" || c === "\r" || c === "\n") { + i++; + continue; + } + if (c === '"') { + const start = i; + i++; + while (i < n) { + if (src[i] === "\\") { + i += 2; + continue; + } + if (src[i] === '"') { + i++; + break; + } + i++; + } + openIndentIfNeeded(); + out += src.slice(start, i); + continue; + } + if (c === "{" || c === "[") { + openIndentIfNeeded(); + out += c; + needIndent = true; + i++; + continue; + } + if (c === "}" || c === "]") { + if (needIndent) { + needIndent = false; + } else { + depth--; + newline(); + } + out += c; + i++; + continue; + } + if (c === ",") { + openIndentIfNeeded(); + out += c; + newline(); + i++; + continue; + } + if (c === ":") { + openIndentIfNeeded(); + out += ": "; + i++; + continue; + } + openIndentIfNeeded(); + out += c; + i++; + } + return out; +} + +/** + * Go's `formatDebugJSON` (`apply.go:285-294`): pretty-print if parseable, else the trimmed raw + * bytes. `JSON.parse` here is used ONLY as a well-formedness check (its result is discarded); + * the actual reformatting goes through {@link legacyGoJsonIndentTokens} so token values are + * never decoded and re-encoded — see that function's own doc comment for why + * `JSON.stringify(JSON.parse(...))` would corrupt the payload Go's `json.Indent` preserves. + */ +export function legacyFormatDebugJson(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) return ""; + try { + JSON.parse(trimmed); + } catch { + return trimmed; + } + return legacyGoJsonIndentTokens(trimmed); +} + +/** Go's `formatApplyIssueMessage` (`apply.go:222-238`). `String(x ?? "")` throughout — see {@link legacyFormatApplyIssue}'s own doc comment for why. */ +function legacyFormatApplyIssueMessage(issue: LegacyPgDeltaApplyIssue): string { + const trimmed = String(issue.message ?? "").trim(); + const message = trimmed.length > 0 ? trimmed : "unknown pg-delta issue"; + const metadata: Array = []; + const code = String(issue.code ?? ""); + if (code.length > 0) metadata.push(`SQLSTATE ${code}`); + if ((issue.position ?? 0) > 0) metadata.push(`position ${issue.position}`); + if (issue.isDependencyError === true) metadata.push("dependency error"); + if (metadata.length === 0) return message; + return `${message} (${metadata.join(", ")})`; +} + +/** + * Go's `formatApplyIssue` (`apply.go:202-221`). Every `issue.statement.*`/`issue.*` field is + * defaulted with `String(x ?? "")` before use — not a bare `?? ""`: a malformed subprocess + * payload (e.g. a pg-delta release that reports `detail`/`hint`/`sql` as a number) can hand any + * of these a non-string value, which `?? ""` alone does not catch (it only substitutes + * `null`/`undefined`), and the very next call on several of these fields is a string-only + * method (`.trim()`, `legacyFormatStatementSql`'s `.split()`) that throws a `TypeError` on + * anything else — turning an actionable SQL error into an unhandled defect, the worst place for + * a rendering bug to exist, since this only ever runs on an ALREADY-FAILED apply. + * + * The no-statement guard checks both `undefined` and `null`: Go's `Statement *ApplyStatement` + * is a pointer, so `{"statement":null,...}` unmarshals to `nil` and `issue.Statement == nil` + * (`apply.go:202`) treats it exactly like a missing field. A `JSON.parse`'d `null` is not + * `=== undefined`, so checking only `undefined` would fall through to `issue.statement.*` and + * throw a `TypeError` instead of rendering the message. + * + * Returns a `Buffer`, not a `string`: the `SQL: ` line embeds {@link legacyFormatStatementSql}'s + * raw bytes directly (via {@link legacyJoinLines}) rather than interpolating them into a + * template string, so a truncation that lands mid-codepoint reaches `output.rawBytes` + * unmodified instead of being silently corrupted by a UTF-8 decode/re-encode round-trip. + */ +function legacyFormatApplyIssue(rawIssue: LegacyPgDeltaApplyIssue | string | null): Buffer { + const issue = legacyNormalizeApplyIssue(rawIssue); + if (issue.statement === undefined || issue.statement === null) { + return Buffer.from(`- ${legacyFormatApplyIssueMessage(issue)}`, "utf-8"); + } + const statementClass = String(issue.statement.statementClass ?? ""); + const classSuffix = statementClass.length > 0 ? ` [${statementClass}]` : ""; + const lines: Array = [ + Buffer.from(`- ${String(issue.statement.id ?? "")}${classSuffix}`, "utf-8"), + Buffer.from(` ${legacyFormatApplyIssueMessage(issue)}`, "utf-8"), + ]; + const detail = String(issue.detail ?? "").trim(); + if (detail.length > 0) lines.push(Buffer.from(` Detail: ${detail}`, "utf-8")); + const hint = String(issue.hint ?? "").trim(); + if (hint.length > 0) lines.push(Buffer.from(` Hint: ${hint}`, "utf-8")); + const sql = legacyFormatStatementSql(String(issue.statement.sql ?? "")); + if (sql.byteLength > 0) { + lines.push(Buffer.concat([Buffer.from(" SQL: ", "utf-8"), sql])); + } + return legacyJoinLines(lines); +} + +/** Go's `formatApplyDiagnosis` (`apply.go:240-258`). `String(x ?? "")` throughout — see {@link legacyFormatApplyIssue}'s own doc comment for why. */ +function legacyFormatApplyDiagnosis(rawDiagnosis: LegacyPgDeltaApplyDiagnosis | null): string { + const diagnosis = legacyNormalizeApplyDiagnosis(rawDiagnosis); + const trimmed = String(diagnosis.message ?? "").trim(); + const message = trimmed.length > 0 ? trimmed : "unknown pg-delta diagnostic"; + let out = "- "; + const code = String(diagnosis.code ?? "").trim(); + if (code.length > 0) out += `[${code}] `; + out += message; + const loc = legacyFormatStatementLocation(diagnosis.statementId); + if (loc.length > 0) out += ` (${loc})`; + const fix = String(diagnosis.suggestedFix ?? "").trim(); + if (fix.length > 0) out += `\n Suggested fix: ${fix}`; + return out; +} + +/** + * Port of Go's `formatApplyFailure` (`apply.go:145-183`): a human-readable summary of an + * unsuccessful pg-delta apply, rendered on failure regardless of `--debug`. `verbose` + * (Go's `viper.GetBool("DEBUG")`) only expands pg-topo diagnostics inline — collapsed to a + * one-line count by default since a large schema can produce hundreds of them. + * + * Returns a `Buffer`, not a `string` — see {@link legacyFormatStatementSql}'s doc comment: + * an embedded truncated SQL statement can be intentionally invalid UTF-8 (matching Go's raw + * byte slice), and only a `Buffer` carried through to `output.rawBytes` reproduces those + * exact bytes instead of a lossy decode/re-encode round-trip. Callers that only need the + * text for display/assertions (this module's own unit tests) can `.toString("utf-8")` it — + * safe for every case except the one pathological truncation this return type exists to + * preserve exactly. + */ +export function legacyFormatApplyFailure( + result: LegacyPgDeltaApplyResult, + verbose: boolean, +): Buffer { + const errors = result.errors ?? []; + const stuckStatements = result.stuckStatements ?? []; + const validationErrors = result.validationErrors ?? []; + const diagnostics = result.diagnostics ?? []; + + let totalStatements = result.totalStatements ?? 0; + if (totalStatements === 0) { + totalStatements = + (result.totalApplied ?? 0) + (result.totalSkipped ?? 0) + stuckStatements.length; + } + + const lines: Array = [ + Buffer.from(`pg-delta apply returned status "${result.status ?? ""}".`, "utf-8"), + Buffer.from( + `${result.totalApplied ?? 0}/${totalStatements} statements applied in ${ + result.totalRounds ?? 0 + } round(s); ${result.totalSkipped ?? 0} skipped.`, + "utf-8", + ), + ]; + if (errors.length > 0) { + lines.push(Buffer.from("Errors:", "utf-8")); + for (const issue of errors) lines.push(legacyFormatApplyIssue(issue)); + } + if (stuckStatements.length > 0) { + lines.push(Buffer.from("Stuck statements:", "utf-8")); + for (const issue of stuckStatements) lines.push(legacyFormatApplyIssue(issue)); + } + if (validationErrors.length > 0) { + lines.push(Buffer.from("Validation errors (from check_function_bodies=on pass):", "utf-8")); + for (const issue of validationErrors) lines.push(legacyFormatApplyIssue(issue)); + } + if (diagnostics.length > 0) { + if (verbose) { + lines.push(Buffer.from("Diagnostics:", "utf-8")); + for (const diagnosis of diagnostics) { + lines.push(Buffer.from(legacyFormatApplyDiagnosis(diagnosis), "utf-8")); + } + } else { + lines.push( + Buffer.from( + `${diagnostics.length} pg-topo diagnostic(s) omitted (re-run with --debug to view).`, + "utf-8", + ), + ); + } + } + // pg-delta may report status "error" without populating any issue arrays (e.g. an internal + // assertion in a future pg-delta release) — point the user at how to get more information + // rather than leaving them with just the bare status line. + if (errors.length === 0 && stuckStatements.length === 0 && validationErrors.length === 0) { + lines.push( + Buffer.from( + [ + "No per-statement diagnostics were reported by pg-delta.", + "Re-run with --debug to print the raw pg-delta payload, or open an issue at", + "https://github.com/supabase/pg-toolbelt/issues with the debug bundle attached.", + ].join("\n"), + "utf-8", + ), + ); + } + return legacyJoinLines(lines); +} + +/** + * Port of Go's `pgdelta.ApplyDeclarative` (`apps/cli-go/internal/pgdelta/apply.go:299-360`): + * applies `declarativeDirAbs` to `target` (the shadow's `contrib_regression` override + * database) via pg-delta's declarative apply engine. Unlike the diff/export/catalog scripts + * (`legacy-pgdelta.ts`), this binds the declarative directory itself read-only at + * `/declarative` rather than mounting the whole project at `/workspace` — Go's own + * `ApplyDeclarative` never needs the wider project tree, only the schema files. `target` is + * always a LOCAL shadow connection (never a remote/Supabase-hosted endpoint), so — unlike + * `legacyDiffPgDelta`'s SOURCE/TARGET — no SSL/CA-bundle preparation applies here, matching + * Go's own plain `"TARGET="+utils.ToPostgresURL(config)` (no TLS handling at all). + */ +export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( + ctx: LegacyPgDeltaContext, + params: { + readonly fs: FileSystem.FileSystem; + /** Absolute host path to the declarative schema directory. */ + readonly declarativeDirAbs: string; + /** The shadow override database's Postgres URL. */ + readonly target: string; + }, +) { + const exists = yield* params.fs + .exists(params.declarativeDirAbs) + .pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return yield* Effect.fail( + new LegacyDeclarativeApplyError({ + message: `declarative schema directory not found: ${params.declarativeDirAbs}`, + }), + ); + } + + const output = yield* Output; + const edgeRuntime = yield* LegacyEdgeRuntimeScript; + // Go's `pgdelta.ApplyDeclarative` reads `viper.GetBool("DEBUG")` (`apply.go:332,342`), which + // falls back to `SUPABASE_DEBUG` via `AutomaticEnv` when `--debug` itself is unset — + // `legacyResolveDebug` (not the bare `LegacyDebugFlag`) reproduces that (review: + // PRRT_kwDOErm0O86XDr4V). By the time either `db diff`/`db pull` reaches here, + // `ParseDatabaseConfig` has already run `Config.Load` -> `loadNestedEnv`, which really + // `os.Setenv`s the merged project `supabase/.env` into the process (`godotenv.Load`, + // `godotenv@v1.5.1/godotenv.go:184-200`) — unlike this port's own `legacyLoadProjectEnv`, + // which is deliberately pure — so a `SUPABASE_DEBUG` set only in `supabase/.env` is visible + // to Go's `viper.GetBool("DEBUG")` here. `legacyResolveDebugWithProjectEnv` reproduces that + // with `ctx.projectEnv` (`legacyReadDbToml`'s merged map, threaded by both `db diff` and + // `db pull`, review: PRRT_kwDOErm0O86XL_oz). + const debug = yield* legacyResolveDebugWithProjectEnv(ctx.projectEnv); + + yield* output.raw("Applying declarative schemas via pg-delta...\n", "stderr"); + + const env: Record = { + SCHEMA_PATH: LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH, + TARGET: params.target, + }; + const binds = [ + `${legacyEdgeRuntimeId(ctx.projectId)}:/root/.cache/deno:rw`, + `${params.declarativeDirAbs}:${LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH}:ro`, + ]; + const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); + const result = yield* edgeRuntime + .run({ + script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeApplyScript, ctx.npmVersion), + env, + binds, + errPrefix: "error running pg-delta script", + extraFiles: npm.extraFiles, + extraEnv: npm.extraEnv, + denoVersion: ctx.denoVersion, + }) + .pipe(Effect.mapError((cause) => new LegacyDeclarativeApplyError({ message: cause.message }))); + + const parsed = yield* Effect.try({ + try: () => { + const raw: unknown = JSON.parse(result.stdout); + // Go's `json.Unmarshal` accepts a top-level JSON `null` for the non-pointer + // `ApplyResult` destination and leaves it zero-valued, with no error (verified + // empirically) — so a `null` payload must fall through to the normal + // `status !== "success"` failure path below, not be misclassified as a parse + // failure. See {@link legacyIsPgDeltaApplyResult}'s own doc comment. + const normalized: unknown = raw === null ? {} : raw; + if (!legacyIsPgDeltaApplyResult(normalized)) { + throw new Error("pg-delta apply output was not a JSON object"); + } + return normalized; + }, + catch: (cause) => + new LegacyDeclarativeApplyError({ + message: debug + ? `failed to parse pg-delta apply output: ${errMessage(cause)}\nstdout: ${result.stdout}` + : `failed to parse pg-delta apply output: ${errMessage(cause)}`, + }), + }); + + if (parsed.status !== "success") { + // `output.rawBytes`, not `output.raw`: `legacyFormatApplyFailure` returns a `Buffer` that + // may contain intentionally-invalid trailing UTF-8 bytes (a truncated SQL statement cut + // mid-codepoint, matching Go's raw byte slice) — decoding it into a string here would + // corrupt exactly the bytes that Buffer exists to preserve. See its own doc comment. + yield* output.rawBytes( + Buffer.concat([legacyFormatApplyFailure(parsed, debug), Buffer.from("\n", "utf-8")]), + "stderr", + ); + if (debug) { + const debugJson = legacyFormatDebugJson(result.stdout); + if (debugJson.length > 0) { + yield* output.raw("pg-delta apply result:\n", "stderr"); + yield* output.raw(`${debugJson}\n`, "stderr"); + } + } + return yield* Effect.fail( + new LegacyDeclarativeApplyError({ + message: `pg-delta declarative apply failed with status: ${parsed.status ?? ""}`, + }), + ); + } + yield* output.raw( + `Applied ${parsed.totalApplied ?? 0} statements in ${parsed.totalRounds ?? 0} round(s).\n`, + "stderr", + ); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts new file mode 100644 index 0000000000..409eeac15f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, test } from "vitest"; + +import { + legacyFormatApplyFailure, + legacyFormatDebugJson, + type LegacyPgDeltaApplyDiagnosis, + type LegacyPgDeltaApplyIssue, + type LegacyPgDeltaApplyResult, + type LegacyPgDeltaApplyStatementLocation, +} from "./legacy-pgdelta.apply.ts"; + +describe("legacyFormatApplyFailure", () => { + test("renders the status + counts summary line, with no per-statement sections when there are no issues", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalStatements: 4, + totalRounds: 2, + totalApplied: 3, + totalSkipped: 1, + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain('pg-delta apply returned status "error".'); + expect(message).toContain("3/4 statements applied in 2 round(s); 1 skipped."); + expect(message).toContain("No per-statement diagnostics were reported by pg-delta."); + expect(message).toContain("https://github.com/supabase/pg-toolbelt/issues"); + }); + + test("derives totalStatements from applied + skipped + stuck when omitted", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalRounds: 1, + totalApplied: 2, + totalSkipped: 1, + stuckStatements: ["stuck one"], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("2/4 statements applied in 1 round(s); 1 skipped."); + }); + + test("renders a structured issue with no `statement` field as its message, with SQLSTATE/position/dependency metadata appended", () => { + const issue: LegacyPgDeltaApplyIssue = { + message: "relation already exists", + code: "42P07", + position: 15, + isDependencyError: true, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("Errors:"); + expect(message).toContain( + "- relation already exists (SQLSTATE 42P07, position 15, dependency error)", + ); + }); + + test("renders a genuine bare string issue (Go's ApplyIssue string-arm) as its own message", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: ["relation already exists"], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("Errors:\n- relation already exists"); + }); + + test("renders a structured issue with its statement id/class, detail, hint, and truncated SQL", () => { + const issue: LegacyPgDeltaApplyIssue = { + message: "column does not exist", + statement: { + id: "001_add_column", + statementClass: "alter_table", + sql: "alter table t add column c int;", + }, + detail: "Column c was dropped earlier in this plan.", + hint: "Check the plan ordering.", + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("- 001_add_column [alter_table]"); + expect(message).toContain(" column does not exist"); + expect(message).toContain(" Detail: Column c was dropped earlier in this plan."); + expect(message).toContain(" Hint: Check the plan ordering."); + expect(message).toContain(" SQL: alter table t add column c int;"); + }); + + test("truncates a multibyte SQL statement by UTF-8 bytes, not UTF-16 code units", () => { + // Go's `formatStatementSQL` (`apply.go:277-283`) truncates via `len(normalized)` and + // `normalized[:maxLen-3]`, both of which count/slice raw UTF-8 bytes. 70 repetitions of a + // single 3-byte CJK character is only 70 JS UTF-16 code units (well under the 120-char + // threshold a naive `.length`/`.slice()` guard would use — it would never truncate at all), + // but 210 UTF-8 bytes — well over Go's 120-byte limit. `117 / 3 === 39` lands the byte cut + // exactly on a codepoint boundary, so the expected output is unambiguous. + const sql = "字".repeat(70); + const issue: LegacyPgDeltaApplyIssue = { + message: "boom", + statement: { id: "001_a", sql }, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(sql.length).toBeLessThanOrEqual(120); + expect(Buffer.byteLength(sql, "utf-8")).toBe(210); + expect(message).toContain(` SQL: ${"字".repeat(39)}...`); + expect(message).not.toContain(sql); + }); + + test("collapses a NEL (U+0085) as whitespace, matching Go's unicode.IsSpace, unlike ECMAScript's `\\s`", () => { + // Go's `formatStatementSQL` (`apply.go:277-283`) normalizes via `strings.Fields`, which + // splits on `unicode.IsSpace` — and `unicode.IsSpace(0x85)` (NEL) is `true` (verified + // empirically), so a NEL embedded in a user's SQL statement is collapsed like any other + // run of whitespace. ECMAScript's `\s` does NOT match NEL, so a naive `.split(/\s+/u)` + // would preserve it verbatim instead of collapsing it. + const nel = String.fromCodePoint(0x85); + const sql = `select${nel}1;`; + const issue: LegacyPgDeltaApplyIssue = { + message: "boom", + statement: { id: "001_a", sql }, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain(" SQL: select 1;"); + expect(message).not.toContain(nel); + }); + + test("preserves a BOM (U+FEFF) instead of treating it as whitespace, matching Go's unicode.IsSpace, unlike ECMAScript's `\\s`", () => { + // The opposite gap from the NEL case above: `unicode.IsSpace(0xFEFF)` (BOM) is `false` + // (verified empirically), so Go's `strings.Fields` keeps a BOM embedded mid-statement as + // part of the surrounding "word" rather than treating it as a separator. ECMAScript's `\s` + // DOES match a BOM, so a naive `.split(/\s+/u)` would incorrectly split on it. + const bom = String.fromCodePoint(0xfeff); + const sql = `select${bom}1;`; + const issue: LegacyPgDeltaApplyIssue = { + message: "boom", + statement: { id: "001_a", sql }, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain(` SQL: select${bom}1;`); + }); + + test("preserves Go's exact (possibly invalid-UTF-8) truncated bytes when the byte cut lands mid-codepoint", () => { + // Unlike the boundary-aligned CJK-repeat case above, a single leading ASCII byte shifts + // every subsequent 3-byte CJK character by one, so the byte-117 cut now lands ONE byte + // into a character instead of exactly on a boundary — reproducing the pathological case + // where Go's raw `normalized[:117]` slice is intentionally invalid UTF-8. Verified against + // Go's own `formatStatementSQL` (`apply.go:277-283`): slicing this exact byte range + // produces a 120-byte result that `unicode/utf8.ValidString` reports as `false`. A naive + // `Buffer#toString("utf-8")` truncation would instead substitute U+FFFD for the incomplete + // trailing sequence, corrupting the byte-exact stderr contract. + const sql = `a${"字".repeat(60)}`; + const issue: LegacyPgDeltaApplyIssue = { + message: "boom", + statement: { id: "001_a", sql }, + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: [issue], + }; + const message = legacyFormatApplyFailure(result, false); + const normalizedBytes = Buffer.from(sql, "utf-8"); + const expectedTruncatedTail = Buffer.concat([ + normalizedBytes.subarray(0, 117), + Buffer.from("...", "utf-8"), + ]); + expect(expectedTruncatedTail.byteLength).toBe(120); + expect( + message.includes(Buffer.concat([Buffer.from(" SQL: ", "utf-8"), expectedTruncatedTail])), + ).toBe(true); + // No replacement character (the tell-tale sign of a lossy UTF-8 decode/re-encode + // round-trip) should ever appear in the output. + expect(message.includes(Buffer.from("�", "utf-8"))).toBe(false); + }); + + test("treats a null errors/stuckStatements/validationErrors/diagnostics array as empty, matching Go's nil-slice decode", () => { + // Go's `encoding/json` accepts a JSON `null` for a `[]T` slice field with no error, + // leaving a nil (zero-length) slice — verified empirically: + // `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` + // with `len(r.Errors) == 0`. `legacyFormatApplyFailure` itself already treats a JS `null`/ + // `undefined` array as empty via `?? []`; this exercises that the TYPE also tolerates it + // (the earlier structural-guard bug — `legacyIsPgDeltaApplyResult` — is covered by the + // integration test in `legacy-pgdelta.apply.integration.test.ts`, since it isn't exported). + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + errors: null, + stuckStatements: null, + validationErrors: null, + diagnostics: null, + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("No per-statement diagnostics were reported by pg-delta."); + expect(message).not.toContain("Errors:"); + expect(message).not.toContain("Stuck statements:"); + }); + + test("stuck statements and validation errors get their own labeled sections", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 0, + totalRounds: 1, + totalSkipped: 0, + stuckStatements: ["still stuck"], + validationErrors: ["bad function body"], + }; + const message = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(message).toContain("Stuck statements:\n- still stuck"); + expect(message).toContain( + "Validation errors (from check_function_bodies=on pass):\n- bad function body", + ); + }); + + test("diagnostics collapse to a one-line count unless verbose", () => { + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 1, + totalRounds: 1, + totalSkipped: 0, + errors: ["some error"], + diagnostics: [{ message: "unused index" }, { message: "missing default" }], + }; + const collapsed = legacyFormatApplyFailure(result, false).toString("utf-8"); + expect(collapsed).toContain("2 pg-topo diagnostic(s) omitted (re-run with --debug to view)."); + expect(collapsed).not.toContain("unused index"); + + const verbose = legacyFormatApplyFailure(result, true).toString("utf-8"); + expect(verbose).toContain("Diagnostics:"); + expect(verbose).toContain("- unused index"); + expect(verbose).toContain("- missing default"); + }); + + test("renders a partially-populated statement (missing sql/statementClass) without throwing", () => { + // Reproduces feeding a real pg-delta subprocess's malformed stdout + // (`{"errors":[{"message":"boom","statement":{"id":"s1"}}]}`) through + // `legacyApplyDeclarativePgDelta` — that function only validates the top-level shape + // (`{status: string}`), not nested fields, and this only ever runs on an + // ALREADY-FAILED apply, so a formatter crash here would turn an actionable SQL error + // into an unhandled defect. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"message":"boom","statement":{"id":"s1"}}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); + expect(message).toContain("- s1"); + expect(message).toContain(" boom"); + expect(message).not.toContain("undefined"); + }); + + test("renders an issue with a null `statement` field as its message, without throwing", () => { + // Reproduces feeding a real pg-delta subprocess's stdout + // (`{"errors":[{"statement":null,"message":"failed"}]}`) through + // `legacyApplyDeclarativePgDelta` — Go's `Statement *ApplyStatement` is a pointer, so + // `"statement":null` unmarshals to `nil` and `formatApplyIssue`'s `issue.Statement == nil` + // (`apply.go:202`) treats it identically to a missing field. A no-statement guard that only + // checks `=== undefined` would fall through to `issue.statement.statementClass` on `null` + // and throw a `TypeError` instead of rendering the message. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"statement":null,"message":"failed"}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); + expect(message).toContain("Errors:\n- failed"); + }); + + test("renders an issue whose detail/hint/sql/statementClass arrived as non-strings without throwing", () => { + // A malformed pg-delta payload can hand any of these fields a non-string value (e.g. a + // future release that reports a numeric `detail`) — a bare `?? ""` guard (rather than + // `String(x ?? "")`) would still pass the number straight to `.trim()`/`.split()` and throw. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"message":"boom","statement":{"id":"s1","statementClass":42,"sql":7},"detail":123,"hint":456}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); + expect(message).toContain("- s1 [42]"); + expect(message).toContain(" Detail: 123"); + expect(message).toContain(" Hint: 456"); + expect(message).toContain(" SQL: 7"); + }); + + test("renders a diagnosis whose message/code/suggestedFix arrived as non-strings without throwing", () => { + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":123,"code":456,"suggestedFix":789}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); + expect(message).toContain("[456] 123"); + expect(message).toContain("Suggested fix: 789"); + }); + + test("drops a diagnosis's statementId when a nested field is mistyped, matching Go's nil fallback", () => { + // Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) tries decoding `statementId` + // as an `ApplyStatementLocation` object first; a mistyped `filePath` (a number, not a + // string) fails that decode, and its bare-string fallback ALSO fails since the value is an + // object, not a string — so Go silently leaves `StatementID` nil, never erroring the whole + // `ApplyResult` parse. Verified empirically against Go's real struct + fallback chain: + // `{"statementId":{"filePath":123,"statementIndex":1}}` decodes with `StatementID == nil`. + // Rendering the raw object anyway (coercing `filePath` via `String(123)`) would show a + // bogus `(123#1)` location Go never emits. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"d","statementId":{"filePath":123,"statementIndex":1}}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); + expect(message).toContain("- d"); + expect(message).not.toContain("123#1"); + expect(message).not.toContain("(123"); + }); + + test("drops a diagnosis's statementId when sourceOffset is mistyped, even though the location renderer never reads it", () => { + // Go's struct-level `json.Unmarshal` into `ApplyStatementLocation` (`apply.go:73-77`) + // fails the moment ANY declared field has the wrong type — including `sourceOffset`, + // which `legacyFormatStatementLocation`/Go's own `formatStatementLocation` never + // display. Verified empirically against Go's real struct: + // `json.Unmarshal([]byte(\`{"filePath":"x.sql","sourceOffset":"bad"}\`), &loc)` returns a + // non-nil error even though `filePath` itself is well-typed, so the object-shape decode + // fails, the bare-string fallback also fails (the value is an object, not a string), and + // Go leaves `StatementID` nil — the location must be dropped, not rendered as `(x.sql)`, + // which would misattribute the diagnostic to a file Go never resolved. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"d","statementId":{"filePath":"x.sql","sourceOffset":"bad"}}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); + expect(message).toContain("- d"); + expect(message).not.toContain("x.sql"); + }); + + test("renders a diagnosis with a null statementId as having no location, without throwing", () => { + // Reproduces a real pg-delta subprocess emitting + // `{"diagnostics":[{"message":"failed","statementId":null}]}` — Go's + // `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) explicitly maps a JSON + // `"statementId":null` to a nil `*ApplyStatementLocation`, and `formatStatementLocation` + // (`apply.go:263-274`) returns `""` for a nil pointer. A guard that only checked + // `resolved === undefined` (not `null`) would fall through to + // `legacyFormatStatementLocation`'s `resolved.filePath` and dereference a `null`, throwing a + // `TypeError` instead of rendering the rest of the diagnostic. + const parsed = JSON.parse( + '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"failed","statementId":null}]}', + ) as LegacyPgDeltaApplyResult; + expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); + const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); + expect(message).toContain("- failed"); + expect(message).not.toContain("undefined"); + }); + + test("a diagnosis with a statementId location and suggestedFix renders both", () => { + const statementId: LegacyPgDeltaApplyStatementLocation = { + filePath: "001_a.sql", + statementIndex: 2, + }; + const diagnosis: LegacyPgDeltaApplyDiagnosis = { + code: "PGT001", + message: "circular dependency", + statementId, + suggestedFix: "Split the statement across two files.", + }; + const result: LegacyPgDeltaApplyResult = { + status: "error", + totalApplied: 1, + totalRounds: 1, + totalSkipped: 0, + errors: ["some error"], + diagnostics: [diagnosis], + }; + const message = legacyFormatApplyFailure(result, true).toString("utf-8"); + expect(message).toContain("- [PGT001] circular dependency (001_a.sql#2)"); + expect(message).toContain("Suggested fix: Split the statement across two files."); + }); +}); + +describe("legacyFormatDebugJson", () => { + test("pretty-prints valid JSON", () => { + expect(legacyFormatDebugJson('{"status":"error","totalApplied":1}')).toBe( + JSON.stringify({ status: "error", totalApplied: 1 }, null, 2), + ); + }); + + test("returns the trimmed raw string when it isn't valid JSON", () => { + expect(legacyFormatDebugJson(" not json ")).toBe("not json"); + }); + + test("returns empty for blank input", () => { + expect(legacyFormatDebugJson(" ")).toBe(""); + }); + + test("preserves an integer literal beyond Number.MAX_SAFE_INTEGER byte-for-byte", () => { + // Go's `json.Indent` (`encoding/json/indent.go`) only inserts whitespace between existing + // tokens — it never decodes a number into a value and re-encodes it. `JSON.parse` would + // decode this literal into a `float64`-backed JS number, silently rounding it (verified: + // `JSON.parse("9007199254740993").toString()` is `"9007199254740992"`), and + // `JSON.stringify` would then re-emit the ROUNDED value — corrupting the exact debug + // payload users are asked to attach to bug reports. + const raw = '{"id":9007199254740993}'; + expect(legacyFormatDebugJson(raw)).toBe('{\n "id": 9007199254740993\n}'); + }); + + test("preserves an existing string escape's exact representation (e.g. an escaped forward slash)", () => { + // Go's `json.Indent` copies string tokens byte-for-byte, so an existing `\/` escape stays + // `\/`. `JSON.stringify(JSON.parse(...))` would instead re-escape the decoded `/` using its + // own (unescaped) convention, changing the payload's exact bytes. + const raw = '{"path":"a\\/b"}'; + expect(legacyFormatDebugJson(raw)).toBe('{\n "path": "a\\/b"\n}'); + }); + + test("matches Go's json.Indent shape for nested objects/arrays, including empty ones", () => { + const raw = '{"a":1,"b":{"c":2,"d":[1,{"e":3}]},"empty":{},"emptyArr":[]}'; + expect(legacyFormatDebugJson(raw)).toBe( + [ + "{", + ' "a": 1,', + ' "b": {', + ' "c": 2,', + ' "d": [', + " 1,", + " {", + ' "e": 3', + " }", + " ]", + " },", + ' "empty": {},', + ' "emptyArr": []', + "}", + ].join("\n"), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 9e97f7105e..d5609ba406 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -5,7 +5,7 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner import { LegacyNetworkIdFlag, LegacyProfileFlag } from "../../../../shared/legacy/global-flags.ts"; import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; -import { containerCliExitCode, spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; @@ -14,8 +14,7 @@ import { localDbContainerId, } from "../../../shared/legacy-docker-ids.ts"; import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; -import { LegacyDeclarativeSeam, type LegacyShadowSource } from "./legacy-pgdelta.seam.service.ts"; -import { legacyInjectPostgresPassword } from "./legacy-pgdelta.seam.url.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; /** * Real `LegacyDeclarativeSeam`: runs the bundled `supabase-go`'s hidden @@ -80,7 +79,9 @@ export const legacyDeclarativeSeamLayer = Layer.effect( // calls `flags.LoadConfig` directly without `LoadProjectRef`, so the // env (read only by LoadProjectRef) never reaches the merge — the Go // command seeds `flags.ProjectRef` from `--project-ref` before - // LoadConfig instead (mirrors `db __shadow`). + // LoadConfig instead (the same trick the Go `db __shadow` hidden + // command used to use, before CLI-1956 removed it in favor of a + // native shadow-provisioning port). ...(projectRef !== undefined ? ["--project-ref", projectRef] : []), ...profileArgs, ]; @@ -359,128 +360,6 @@ export const legacyDeclarativeSeamLayer = Layer.effect( ); }), ), - provisionShadow: ({ mode, targetLocal, usePgDelta, schema, projectRef }) => - Effect.scoped( - Effect.gen(function* () { - if (!("found" in resolved)) { - return yield* Effect.fail( - new LegacyDeclarativeShadowDbError({ - message: - "Could not find the supabase-go binary required to provision the shadow database.", - }), - ); - } - const args = [ - "db", - "__shadow", - "--mode", - mode, - ...(targetLocal ? ["--target-local"] : []), - ...(usePgDelta ? ["--use-pg-delta"] : []), - ...(schema.length > 0 ? ["--schema", schema.join(",")] : []), - ...(Option.isSome(networkId) ? ["--network-id", networkId.value] : []), - // Linked path only: pass the resolved ref so the hidden `db __shadow` - // child's LoadConfig merges the matching `[remotes.]` override - // into the shadow baseline (db.major_version, service enables, vault), - // matching the Go monolith which builds the shadow from the - // remote-merged config. A flag (not env) keeps the Go-proxy channel - // parity and avoids over-merging on local/db-url shadows. - ...(projectRef !== undefined ? ["--project-ref", projectRef] : []), - ...profileArgs, - ]; - const command = ChildProcess.make(resolved.found, args, { - cwd: cliConfig.workdir, - stdin: "inherit", - stdout: "pipe", - stderr: "inherit", - extendEnv: true, - // Disable the child's telemetry so the hidden `db __shadow` seam - // doesn't record its own `cli_command_executed` (and run Go post-run - // work) on top of the user's TS command, matching the explicit - // LegacyGoProxy delegates which set the same env. - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, - detached: false, - }); - const handle = yield* spawner.spawn(command).pipe( - Effect.mapError( - () => - new LegacyDeclarativeShadowDbError({ - message: "failed to run the shadow-database provisioner (supabase-go).", - }), - ), - ); - const chunks: Array = []; - yield* Stream.runForEach(handle.stdout, (chunk) => - Effect.sync(() => { - chunks.push(chunk); - }), - ).pipe(Effect.mapError(() => failure())); - const exitCode = yield* handle.exitCode.pipe(Effect.mapError(() => failure())); - if (exitCode !== 0) { - return yield* Effect.fail(failure(exitCode)); - } - const total = chunks.reduce((size, chunk) => size + chunk.length, 0); - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - // stdout is three newline-separated lines: container id, source URL, - // and an optional target-override URL (empty unless the local-target - // declarative branch redirected the target to a second shadow db). - // The URLs arrive WITHOUT a password — the Go seam prints them via - // ToPostgresURLWithoutPassword so it never logs a credential to stdout - // (CWE-312). The shadow uses the local Postgres password, so we re-inject - // the password resolved from config.toml before handing the URLs to the - // differ / sql-pg connection. On the linked path the child built the - // shadow from the remote-merged config (via --project-ref), so re-read - // with the same ref to pick up a `[remotes.].db.password` override — - // otherwise the injected password wouldn't match the shadow's and the - // connection would fail auth. Absent (local/db-url) → base config. - const lines = new TextDecoder().decode(bytes).split(/\r?\n/u); - const container = (lines[0] ?? "").trim(); - const sourceUrl = (lines[1] ?? "").trim(); - const targetOverride = (lines[2] ?? "").trim(); - if (container.length === 0 || sourceUrl.length === 0) { - return yield* Effect.fail(failure()); - } - const password = yield* legacyReadDbToml(fs, path, cliConfig.workdir, projectRef).pipe( - Effect.map((toml) => toml.password), - Effect.mapError( - () => - new LegacyDeclarativeShadowDbError({ - message: - "failed to read the local database password from config.toml to connect to the shadow database.", - }), - ), - ); - return { - container, - sourceUrl: legacyInjectPostgresPassword(sourceUrl, password), - targetUrlOverride: - targetOverride.length > 0 - ? legacyInjectPostgresPassword(targetOverride, password) - : undefined, - } satisfies LegacyShadowSource; - }), - ), - removeShadowContainer: (container) => - Effect.gen(function* () { - if (container.length === 0) return; - // Remove the shadow left running by provisionShadow. Best-effort — a - // failure here must never mask the diff result. `-v` removes the - // Postgres anonymous data volume too, matching Go's `DockerRemove` - // (`RemoveOptions{RemoveVolumes: true, Force: true}`, - // `internal/utils/docker.go:330`); without it every shadow leaves a - // dangling volume behind. - yield* containerCliExitCode(spawner, ["rm", "-f", "-v", container], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - extendEnv: true, - }).pipe(Effect.ignore); - }), }); }), ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index 4f5409c3a6..d56ac2c551 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -9,40 +9,20 @@ import type { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts" * that used it (`db diff`'s explicit `--from/--to migrations`, and * `db schema declarative sync`'s migrations-catalog diff source) now resolve * natively — see `legacy-pgdelta.cache.ts`'s `legacyResolveMigrationsCatalogRef` - * and `legacyGetMigrationsCatalogRef` respectively. `"baseline"` and + * and `legacyGetMigrationsCatalogRef` respectively; CLI-1956 then ported the + * shadow those two functions provision off the Go seam too (see + * `legacy-pgdelta.cache.ts`'s `exportViaShadowCatalog`), so nothing under this + * seam provisions a shadow database at all any more. `"baseline"` and * `"declarative"` remain seam-backed because they need a shadow provisioned with * ONLY the platform baseline (no migrations) or with declarative files applied — * neither has a native TS equivalent yet (`start.SetupDatabase` against an * arbitrary shadow, and `pgdelta.ApplyDeclarative`), and porting either - * overlaps with CLI-1956's in-progress native shadow-provisioning work. CLI-1823 - * (native pg-delta lib) and CLI-1956 are the tracked follow-ups for retiring the - * rest of this seam. + * overlaps with CLI-1956's native shadow-provisioning work. CLI-1823 (native + * pg-delta lib) and CLI-1956's remaining follow-ups are the tracked next steps + * for retiring the rest of this seam. */ export type LegacyCatalogMode = "baseline" | "declarative"; -/** - * Which live shadow database the Go seam should provision and leave running: - * - `diff`: platform baseline + local migrations (the `db diff` / migration-style - * `db pull` diff source), plus the local-target declarative branch. - * - `declarative`: a bare shadow with no baseline/migrations (the `db pull - * --declarative` empty export source). - */ -type LegacyShadowMode = "diff" | "declarative"; - -/** A live shadow database left running for the caller to diff against and remove. */ -export interface LegacyShadowSource { - /** Container id; the caller removes it via `removeShadowContainer` when done. */ - readonly container: string; - /** The diff source Postgres URL (the provisioned shadow). */ - readonly sourceUrl: string; - /** - * When set, replaces the diff target with a second shadow database - * (`contrib_regression` with declarative schemas applied). Mirrors Go's - * local-target declarative branch, where the user's local DB is not diffed. - */ - readonly targetUrlOverride: string | undefined; -} - interface LegacyDeclarativeSeamShape { /** * Provisions the shadow-database platform baseline (and, for `declarative`, @@ -51,10 +31,13 @@ interface LegacyDeclarativeSeamShape { * path of the exported pg-delta catalog (cached under `supabase/.temp/pgdelta/`). * Go's progress is teed to stderr; only the catalog path is captured from stdout. * - * This is the seam for `start.SetupDatabase` (the auth/storage/realtime service - * migrations) run against an arbitrary shadow, and for `pgdelta.ApplyDeclarative` - * (the `declarative` mode), neither of which is yet ported to TypeScript - * (CLI-1959/CLI-1956/CLI-1823 — see {@link LegacyCatalogMode}'s doc comment). + * The shadow-database provisioning this needs (`start.SetupDatabase`, the + * auth/storage/realtime service migrations) IS now natively ported + * (`legacySetupDatabase`, `shared/db-bootstrap/db-setup.ts`, CLI-1956) — `db diff`/ + * `db pull` no longer go through this Go seam for their own shadow at all (see + * `commands/db/shared/legacy-shadow-source.ts`). This method stays Go-delegated + * only because `db schema declarative generate`/`sync` haven't been natively + * ported yet, not because the underlying shadow primitive is missing. */ readonly exportCatalog: (opts: { readonly mode: LegacyCatalogMode; @@ -93,33 +76,6 @@ interface LegacyDeclarativeSeamShape { void, LegacyDeclarativeShadowDbError >; - /** - * Provisions a live shadow database via the bundled Go binary's hidden - * `db __shadow` command and returns it running (the container is NOT removed — - * the caller must call `removeShadowContainer` when the diff completes). This - * is the diff "source" that both the migra and pg-delta engines run against in - * `db diff` / `db pull`, mirroring Go's `DiffDatabase` (`differ(shadow, target)`). - * Go's shadow-provisioning progress is teed to stderr. - */ - readonly provisionShadow: (opts: { - readonly mode: LegacyShadowMode; - readonly targetLocal: boolean; - readonly usePgDelta: boolean; - readonly schema: ReadonlyArray; - /** - * Resolved linked project ref, passed ONLY on the `--linked` path so the - * shadow merges the matching `[remotes.]` config override (Go builds the - * shadow from the already-remote-merged global config on the linked path). - * Omitted for local/db-url shadows, which Go never remote-merges. - */ - readonly projectRef?: string; - }) => Effect.Effect; - /** - * Removes a shadow database container left running by `provisionShadow` - * (`docker rm -f `). Best-effort: a failure to remove is swallowed so it - * never masks the underlying diff result. - */ - readonly removeShadowContainer: (container: string) => Effect.Effect; } export class LegacyDeclarativeSeam extends Context.Service< diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.ts deleted file mode 100644 index 644586df5d..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Injects the Postgres password into a connection URL that the Go `db __shadow` - * seam emitted WITHOUT one. - * - * The Go seam prints the shadow source/target URLs via - * `ToPostgresURLWithoutPassword` so it never writes a credential to stdout - * (CWE-312). The shadow database always uses the local Postgres password - * (`utils.Config.Db.Password`), which the TS caller resolves independently from - * `config.toml` (`legacyReadDbToml().password`) — so we re-attach it here before - * the URL is handed to the differ (migra / pg-delta) or a sql-pg connection. - * - * The host, port, database, and query params are left exactly as the Go seam - * produced them (Go remains the authority for IPv6 bracketing, `connect_timeout`, - * and runtime params); only the userinfo password is set. The `URL` setter - * percent-encodes the password, matching Go's `url.UserPassword` encoding, and - * the pg driver decodes it back to the same secret. - */ -export function legacyInjectPostgresPassword(connectionUrl: string, password: string): string { - const url = new URL(connectionUrl); - url.password = password; - return url.toString(); -} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.unit.test.ts deleted file mode 100644 index f8298aa30d..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.url.unit.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { legacyInjectPostgresPassword } from "./legacy-pgdelta.seam.url.ts"; - -describe("legacyInjectPostgresPassword", () => { - it("injects the password into a password-less IPv4 shadow URL", () => { - expect( - legacyInjectPostgresPassword( - "postgresql://postgres@127.0.0.1:54320/postgres?connect_timeout=10", - "postgres", - ), - ).toBe("postgresql://postgres:postgres@127.0.0.1:54320/postgres?connect_timeout=10"); - }); - - it("preserves IPv6 bracketing, the database name, and query params", () => { - expect( - legacyInjectPostgresPassword( - "postgresql://postgres@[::1]:54320/contrib_regression?connect_timeout=10&options=test", - "postgres", - ), - ).toBe( - "postgresql://postgres:postgres@[::1]:54320/contrib_regression?connect_timeout=10&options=test", - ); - }); - - it("percent-encodes a password with special characters so it round-trips", () => { - const injected = legacyInjectPostgresPassword( - "postgresql://postgres@127.0.0.1:54320/postgres?connect_timeout=10", - "p@ss:w/rd", - ); - expect(injected).toBe( - "postgresql://postgres:p%40ss%3Aw%2Frd@127.0.0.1:54320/postgres?connect_timeout=10", - ); - // The pg driver decodes the userinfo back to the original secret. - expect(decodeURIComponent(new URL(injected).password)).toBe("p@ss:w/rd"); - }); - - it("overwrites any existing userinfo password", () => { - expect( - legacyInjectPostgresPassword( - "postgresql://postgres:stale@127.0.0.1:54320/postgres?connect_timeout=10", - "fresh", - ), - ).toBe("postgresql://postgres:fresh@127.0.0.1:54320/postgres?connect_timeout=10"); - }); -}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts new file mode 100644 index 0000000000..2bf078d9c2 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts @@ -0,0 +1,826 @@ +/** + * The composed shadow-database shapes `db diff`/`db pull` actually call — Go's + * `PrepareShadowSource`/`PrepareRawShadow` (`apps/cli-go/internal/db/diff/shadow.go`), built + * on top of `shared/db-bootstrap/shadow-database.ts`'s lower-level primitives plus the + * `--target-local` declarative-schema branch (Go's `loadDeclaredSchemas`/ + * `shouldApplyDeclarativeWithPgDelta`/`migrateBaseDatabase`, `internal/db/diff/diff.go:52-115, + * 261-274`) and pg-delta's declarative apply engine (`legacy-pgdelta.apply.ts`). + * + * Go's `PrepareShadowSource(ctx, schema []string, targetLocal, usePgDelta bool, fsys, + * options...)` takes a `schema` parameter that is NEVER referenced anywhere in the function + * body (verified by reading the whole function) — dead code in Go itself, making the `--schema` + * flag the now-removed `db __shadow` hidden seam used to forward here a no-op even before + * CLI-1956 deleted that seam in favor of this native port. Deliberately NOT ported here: there + * is nothing to port. + */ + +import { Effect, Option, Result, type FileSystem, type Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import type { GlobalFlag } from "effect/unstable/cli"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import type { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import type { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { legacyBold } from "../../../shared/legacy-colors.ts"; +import type { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyResolveDeclarativeDir, + legacyResolveSeedSqlPath, + type LegacyPgDeltaTomlConfig, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import type { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; +import { legacyResolveUnderWorkdir, legacyGlobPattern } from "../../../shared/legacy-glob.ts"; +import type { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import type { LegacyImagePrepullError } from "../../../shared/db-bootstrap/image-prepull.ts"; +import type { LegacyHealthCheckTimeoutError } from "../../../shared/db-bootstrap/health-check.ts"; +import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/health-check.ts"; +import { legacySeedGlobals } from "../../../shared/legacy-migration-apply.ts"; +import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "../../../shared/legacy-path-match.ts"; +import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; +import type { LegacyLocalDbContainerInputs } from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import type { LegacyVaultSecret } from "../../../shared/legacy-vault.ts"; +import { + legacyMigrateShadowDatabase, + LegacyShadowDbError, + type LegacyShadowConnectionInput, + type LegacyShadowDatabaseHandle, + type LegacyShadowDbSetupInput, + type LegacyShadowSourceResult, +} from "../../../shared/db-bootstrap/shadow-database.ts"; +import type { LegacyStartSetupLocalDatabaseError } from "../../../shared/db-bootstrap/db-setup.ts"; +import { + LegacyDeclarativeApplyError, + legacyApplyDeclarativePgDelta, +} from "./legacy-pgdelta.apply.ts"; +import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; +import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; + +type Spawner = ChildProcessSpawner["Service"]; + +export type { LegacyShadowSourceResult }; + +/** + * Adapts {@link LegacyLocalDbContainerInputs} (`local-container-inputs.ts`, the SAME + * config/image/JWKS resolution prelude `db start`/`db reset` share) plus the caller's own + * already-loaded `config.toml` slice into {@link LegacyShadowConnectionInput} + * (`shadow-database.ts`) — every field {@link legacyPrepareShadowSource}/ + * `legacyPrepareRawShadow` (`shadow-database.ts`) need EXCEPT the diff/pull-specific ones + * (`targetLocal`/`usePgDelta`/`schemaPaths`/`pgDelta`/`ctx`/`setup`, left to each call site). + * Hoisted here so `db diff`/`db pull` don't each declare an identical ~20-field object literal. + * + * On `db diff --linked`/`db pull` (linked), the caller passes its own resolved ref straight + * through to {@link legacyBuildLocalDbContainerInputs} (its own `projectRef` parameter — see + * that function's doc comment), which threads it into `legacyLoadLocalProjectContext` -> + * `loadProjectConfig({ projectRef })`. So the shadow's OWN container config (image, JWT + * secret, root key, `db.settings`, service enabled-for-setup flags, sourced from + * `localInputs.context.config`/`postgresSpecBase`) reflects the matching `[remotes.]` + * override, same as `toml` (the caller's own `legacyReadDbToml(..., linkedRef)` result, + * which feeds `pgDelta`/vault/`apiAutoExposeNewTables` below) — matching Go's own uniform + * remote-merge on the linked path (`LoadConfig` seeds `flags.ProjectRef` before every field + * read). The two config reads still go through independent remote-merge implementations + * (`@supabase/config`'s `applyRemoteOverride` for `localInputs.context.config`; + * `legacy-db-config.toml-read.ts`'s own TOML-based merge for `toml`) rather than a single + * shared decode — unifying those is a larger, out-of-scope refactor, not a per-command gap. + */ +export function legacyShadowRunInputFromLocalContainerInputs( + localInputs: LegacyLocalDbContainerInputs, + resolvedImage: string, + toml: { + readonly shadowPort: number; + readonly password: string; + readonly baseline: { readonly apiAutoExposeNewTables: Option.Option }; + readonly vault: ReadonlyArray; + }, + fs: FileSystem.FileSystem, + path: Path.Path, +): Omit< + LegacyPrepareShadowSourceInput, + "targetLocal" | "usePgDelta" | "schemaPaths" | "pgDelta" | "ctx" +> { + const { postgresSpecBase } = localInputs; + return { + db: { + major_version: postgresSpecBase.db.major_version, + settings: postgresSpecBase.db.settings, + }, + experimental: postgresSpecBase.experimental, + jwtSecret: postgresSpecBase.jwtSecret, + jwtExpiry: postgresSpecBase.jwtExpiry, + networkId: localInputs.networkId, + image: resolvedImage, + configImage: postgresSpecBase.configImage, + rootKey: postgresSpecBase.rootKey, + shadowPort: toml.shadowPort, + projectId: localInputs.context.projectId, + isBitbucketPipeline: localInputs.containerOpts.isBitbucketPipeline, + workdir: localInputs.containerOpts.workdir, + extraHosts: localInputs.containerOpts.extraHosts, + fs, + path, + hostname: localInputs.context.hostname, + password: toml.password, + healthTimeoutSeconds: localInputs.dbHealthTimeoutSeconds, + setup: { + majorVersion: localInputs.setup.majorVersion, + config: localInputs.setup.config, + // NOT `localInputs.setup.dbUrl` — that carries the REGULAR local container's own + // hardcoded-"postgres" password (`legacy-local-config-values.ts`'s `DEFAULT_DB_PASSWORD`), + // for a DIFFERENT container. The shadow's own one-shot setup jobs + // (`legacyBuildShadowSetupDatabaseInput`) only ever consume this `dbUrl` to extract a + // password (`legacyStartInternalDbPassword`) for the SHADOW they actually run against, so + // it must carry the SAME resolved `toml.password` the shadow container itself is + // initialized with (see `legacyBuildShadowPostgresContainerSpec`) — otherwise a non-default + // `[db] password` authenticates against the wrong secret and every setup job fails. + dbUrl: legacyToPostgresURL({ + host: localInputs.context.hostname, + port: toml.shadowPort, + user: "postgres", + password: toml.password, + database: "postgres", + }), + jwtSecret: localInputs.setup.jwtSecret, + jwks: localInputs.setup.jwks, + apiUrl: localInputs.setup.apiUrl, + authExternalUrl: localInputs.setup.authExternalUrl, + siteUrl: localInputs.setup.siteUrl, + anonKey: localInputs.setup.anonKey, + serviceRoleKey: localInputs.setup.serviceRoleKey, + storageTargetMigration: localInputs.setup.storageTargetMigration, + realtimeEnabledForSetup: localInputs.setup.realtimeEnabledForSetup, + storageEnabledForSetup: localInputs.setup.storageEnabledForSetup, + authEnabledForSetup: localInputs.setup.authEnabledForSetup, + serviceVersionOverrides: localInputs.setup.serviceVersionOverrides, + projectEnvValues: localInputs.setup.projectEnvValues, + debug: localInputs.setup.debug, + apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, + vault: toml.vault, + }, + }; +} + +export interface LegacyPrepareShadowSourceInput extends LegacyShadowConnectionInput { + readonly setup: LegacyShadowDbSetupInput; + /** Go's `utils.IsLocalDatabase(config)` — the only target-derived input the shadow prep needs. */ + readonly targetLocal: boolean; + /** Selects the declarative-apply engine for the local-declared branch, matching `DiffDatabase`. */ + readonly usePgDelta: boolean; + /** `db.migrations.schema_paths`, RAW (unresolved) — Go's `Config.Db.Migrations.SchemaPaths` pre-`config.go:976-979`-resolution form. */ + readonly schemaPaths: ReadonlyArray; + readonly pgDelta: LegacyPgDeltaTomlConfig; + /** Ambient pg-delta edge-runtime context, only read on the pg-delta declarative-apply sub-branch. */ + readonly ctx: LegacyPgDeltaContext; +} + +/** Every failure {@link legacyPrepareShadowSource} can produce, beyond its own `E` (JWKS resolution). */ +export type LegacyPrepareShadowSourceError = + | LegacyShadowDbError + | LegacyDeclarativeShadowDbError + | LegacyHealthCheckTimeoutError + | LegacyStartSetupLocalDatabaseError + | LegacyImagePrepullError + | LegacyDeclarativeApplyError; + +/** + * Port of Go's `PrepareShadowSource` (`apps/cli-go/internal/db/diff/shadow.go:37-91`): + * health-wait against an already-`legacyCreateShadowDatabase`-created shadow -> + * `MigrateShadowDatabase` (platform baseline + local migrations + the `contrib_regression` + * template database) -> build the diff-source config -> when `targetLocal`, the + * declarative-schema override branch. + * + * Deliberately does NOT call `legacyCreateShadowDatabase` (`shadow-database.ts`) itself, and + * no longer wraps its own body in `Effect.onError` cleanup — the caller does both, structuring + * this function as the `use` phase of an `Effect.acquireUseRelease` whose `acquire` is + * `legacyCreateShadowDatabase` and whose `release` is `legacyRemoveShadowDatabase` (see + * `diff.handler.ts`/`pull.handler.ts`'s call sites). An earlier shape passed THIS WHOLE + * function (create -> health-wait -> migrate -> declarative-apply) as `acquire` instead — + * matching Go's `ok`-sentinel + `defer` pattern for the "remove on any failure after + * creation" case, but Effect's `acquireUseRelease` runs `acquire` inside an + * `uninterruptibleMask` with no `restore` (`uninterruptibleMask(restore => + * flatMap(acquire, a => onExitPrimitive(restore(use(a)), ...)))`), so passing all of this + * function as `acquire` made the ENTIRE health-wait/migration-replay/declarative-apply + * sequence uninterruptible too — a SIGINT during any of it (each of which can run for + * seconds to minutes) was silently swallowed until the whole sequence finished on its own, + * unlike Go, which threads one cancellable `ctx` through every one of these calls. Moving + * creation out to the (brief, Docker-API-bound) `acquire` and keeping this sequence as the + * `use` phase restores that parity: a SIGINT here now interrupts immediately, same as Go's + * ctx cancellation, while `legacyRemoveShadowDatabase` still runs as the `release` finalizer + * regardless of how `use` exits — success, a typed failure, or an interrupt (review: + * PRRT_kwDOErm0O86XMrID). + */ +export const legacyPrepareShadowSource = ( + spawner: Spawner, + handle: LegacyShadowDatabaseHandle, + input: LegacyPrepareShadowSourceInput, +): Effect.Effect< + LegacyShadowSourceResult, + LegacyPrepareShadowSourceError | E, + | Output + | LegacyDockerRun + | RuntimeInfo + | HttpClient.HttpClient + | LegacyDbConnection + | LegacyEdgeRuntimeScript + | GlobalFlag.Setting.Identifier<"debug"> + // `legacyApplyDeclarativePgDelta`'s own `legacyResolveDebugWithProjectEnv` (viper + // `AutomaticEnv` `SUPABASE_DEBUG` fallback, plus the project `.env` Go's `loadNestedEnv` + // has already `os.Setenv`'d into the process by this point, review: PRRT_kwDOErm0O86XDr4V, + // PRRT_kwDOErm0O86XL_oz) needs `CliArgs` to detect an explicit `--debug=false`, same as + // `legacyResolveYes`/`legacyResolveExperimental`. + | CliArgs +> => + Effect.gen(function* () { + const { containerId, secretDirId } = handle; + + yield* legacyWaitForHealthyServices(spawner, [containerId], { + timeoutSeconds: input.healthTimeoutSeconds, + }); + + const connConfig: LegacyPgConnInput = { + host: input.hostname, + port: input.shadowPort, + user: "postgres", + password: input.password, + database: "postgres", + }; + yield* legacyMigrateShadowDatabase(spawner, { + fs: input.fs, + path: input.path, + workdir: input.workdir, + projectId: input.projectId, + container: containerId, + networkId: input.networkId, + connConfig, + setup: input.setup, + }); + + const sourceUrl = legacyToPostgresURL(connConfig); + + let targetUrlOverride: string | undefined; + if (input.targetLocal) { + const declared = yield* legacyLoadDeclaredSchemas( + input.fs, + input.path, + input.workdir, + input.schemaPaths, + input.pgDelta, + ); + if (declared.length > 0) { + const overrideConn: LegacyPgConnInput = { ...connConfig, database: "contrib_regression" }; + const useDeclarativePgDelta = legacyShouldApplyDeclarativeWithPgDelta( + input.path, + input.usePgDelta, + input.schemaPaths, + input.pgDelta, + ); + let appliedViaPgDelta = false; + if (useDeclarativePgDelta) { + const declDirRel = legacyResolveDeclarativeDir(input.path, input.pgDelta); + const declDirAbs = legacyResolveUnderWorkdir(input.path, input.workdir, declDirRel); + // Go's `afero.DirExists` (`shadow.go:72`) — a non-directory path is treated as + // absent here too, same reasoning as `legacyLoadDeclaredSchemas` below. + const declDirExists = yield* input.fs.stat(declDirAbs).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + if (declDirExists) { + yield* legacyApplyDeclarativePgDelta(input.ctx, { + fs: input.fs, + declarativeDirAbs: declDirAbs, + target: legacyToPostgresURL(overrideConn), + }); + appliedViaPgDelta = true; + } + } + if (!appliedViaPgDelta) { + yield* legacyMigrateBaseDatabase( + input.fs, + input.path, + input.workdir, + overrideConn, + declared, + ); + } + targetUrlOverride = legacyToPostgresURL(overrideConn); + } + } + + return { + container: containerId, + secretDirId, + sourceUrl, + targetUrlOverride, + } satisfies LegacyShadowSourceResult; + }); + +/** Go's `pkg/config.hasGlobMeta` (`config.go:211-213`) — `*?[` only, NOT `io/fs.hasMeta`'s broader set (which also counts `\`). */ +function legacyHasConfigGlobMeta(pattern: string): boolean { + return /[*?[]/u.test(pattern); +} + +/** + * Go's `sort.Strings` compares byte-wise over each string's UTF-8 encoding; JS's default + * `Array.prototype.sort()` instead compares UTF-16 CODE UNITS, which diverges from byte/codepoint + * order for a supplementary-plane character (encoded as a surrogate pair, code units + * `0xD800`-`0xDBFF` + `0xDC00`-`0xDFFF`) alongside a BMP private-use character (`0xE000`- + * `0xFFFF`): JS ranks the surrogate pair BEFORE the private-use character (`0xD800 < 0xE000`), + * while Go's UTF-8 byte order — which preserves Unicode codepoint order — ranks the + * supplementary-plane codepoint (`>= U+10000 > U+FFFF`) AFTER it. Verified empirically: + * `["a\u{1F600}.sql","a.sql"].sort()` (default) disagrees with `Buffer.compare` on the + * same two strings' UTF-8 bytes. Used for every `sort.Strings` this module ports so a schema + * directory with such filenames applies in the same order Go would. + */ +// Exported (not module-private) so `legacy-pgdelta.cache.ts`'s `legacyListLocalMigrations` — the +// same command family (`commands/db/shared/`), per `apps/cli/CLAUDE.md`'s "Hoist Before You +// Duplicate" rule — can reuse it for Go's `fs.ReadDir`-backed `migration.ListLocalMigrations` +// (`pkg/migration/list.go:34`), which is byte-sorted the exact same way (review: +// PRRT_kwDOErm0O86W3OyD). +export function legacyCompareUtf8Bytes(a: string, b: string): number { + return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); +} + +/** + * Manual, no-follow-symlink directory walk shared by `legacyGlobDeclaredSchemaPaths` (Go's + * `walkMatchedDir`/`fs.WalkDir`) and `legacyWalkSqlFilesSorted` (Go's `afero.Walk`). Both Go + * walkers are `Lstat`-based and therefore never descend into a symlinked directory — + * `io/fs.WalkDir`'s doc comment: "WalkDir does not follow symbolic links found in directories, + * but if root itself is a symbolic link, its target will be walked"; `afero.walk` confirms the + * same via its own `lstatIfPossible` call (`github.com/spf13/afero/path.go`), which reports a + * symlinked subdirectory's `IsDir()` as false so the recursive `walk` call returns without + * descending. Effect's `FileSystem.readDirectory(dir, { recursive: true })` is instead backed by + * Node's recursive `fs.readdir` (`NodeFileSystem.ts`'s `readDirectory` passes `options` straight + * to `fs.promises.readdir`), which DOES follow symlinked subdirectories — verified empirically: a + * directory containing a symlink to an external directory has the external directory's files + * appear in the recursive listing. Left uncorrected, a schema directory symlinking outside the + * configured schema tree would leak external `.sql` files into a local-target diff/pull that Go + * would never have picked up. Walking manually here, one `fs.readDirectory(dir)` (non-recursive) + * per level, and testing each entry with `readLink` BEFORE `stat` (the same no-follow-detector + * idiom as `cp.handler.ts`'s `walkUploadDir`) — skipping a symlinked directory entirely, exactly + * like Lstat-based Go — reproduces that behavior. Both Go walkers also finish with a plain + * `sort.Strings` over the complete set of collected paths (`config.go:186`, + * `internal/db/diff/diff.go:75,95`), which is a full lexicographic sort over full relative paths, + * NOT merely a per-directory-level sort — so the final `.sort()` below is required even though + * entries are already read in sorted order at each level; it uses {@link legacyCompareUtf8Bytes}, + * not JS's default comparator, to match Go's byte order — see that function's own doc comment. + * A per-entry `fs.stat` failure (permission denied, I/O error, a concurrent filesystem change + * between `readDirectory` and `stat`) is NOT swallowed: both Go walkers pass the entry's error to + * their callback, which returns it and aborts the whole walk — silently treating it as "file + * absent" here could build an incomplete declarative target instead. + */ +function legacyWalkRegularSqlFilesNoFollow( + fs: FileSystem.FileSystem, + path: Path.Path, + rootAbs: string, +): Effect.Effect, PlatformError> { + return Effect.gen(function* () { + const result: Array = []; + + const visit = (dirAbs: string, dirRel: string): Effect.Effect => + Effect.gen(function* () { + // Go's `os.ReadDir`/`io/fs.ReadDir` (backing both `fs.WalkDir` and `afero.Walk` — see + // `afero/path.go`'s `readDirNames`) byte-sort EVERY directory level via + // `bytealg.CompareString`/`sort.Strings`, not just a final flattened result — so the + // traversal order itself (which determines which entry's read/stat error surfaces + // first when a walk aborts early) must use {@link legacyCompareUtf8Bytes} here too, not + // JS's default UTF-16-code-unit comparator (review: PRRT_kwDOErm0O86XAlIo). + const names = [...(yield* fs.readDirectory(dirAbs))].sort(legacyCompareUtf8Bytes); + for (const name of names) { + const entryAbs = path.join(dirAbs, name); + const entryRel = dirRel === "" ? name : `${dirRel}/${name}`; + const isSymlink = yield* fs.readLink(entryAbs).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (isSymlink) continue; + const entryStat = yield* fs.stat(entryAbs); + if (entryStat.type === "Directory") { + yield* visit(entryAbs, entryRel); + } else if (entryStat.type === "File" && entryRel.endsWith(".sql")) { + result.push(entryRel); + } + } + }); + + yield* visit(rootAbs, ""); + return result.sort(legacyCompareUtf8Bytes); + }); +} + +/** + * Port of Go's `Glob.SQLFiles(fsys, WithSkipEmptyGlobs(), WithErrorOnAllSkippedGlobs())` + * (`apps/cli-go/pkg/config/config.go:119-192`), the exact option combination + * `loadDeclaredSchemas`'s `schema_paths` branch uses. Deliberately separate from + * `legacy-migrate-and-seed.ts`'s `legacyResolveSchemaPathFiles` (Go's SAME `Glob.SQLFiles` + * with ZERO options, `applySchemaFiles`) — the two option sets are genuinely different: a + * per-pattern "no files matched" is unconditionally an error here UNLESS the pattern + * contains a glob metacharacter (`skipEmptyGlobs`), in which case it's only converted back + * into an error when EVERY pattern ended up skipped and the combined result is still empty + * (`errorOnAllSkippedGlobs`) — and, unlike `applySchemaFiles`'s caller (which swallows any + * collected errors once `len(declared) > 0`), `loadDeclaredSchemas`'s caller propagates + * ANY error unconditionally, regardless of whether other patterns matched. + */ +function legacyGlobDeclaredSchemaPaths( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + patterns: ReadonlyArray, +): Effect.Effect, LegacyDeclarativeShadowDbError> { + return Effect.gen(function* () { + const seen = new Set(); + const result: Array = []; + const problems: Array = []; + const skipped: Array = []; + + for (const rawPattern of patterns) { + // Go's `config.go:976-979`: a non-empty, non-absolute `schema_paths` entry is resolved + // under `supabase/` (via `path.Join`, which also cleans the result) at config-load + // time — `legacyResolveSeedSqlPath` already implements the identical resolution Go + // applies to `[db.seed] sql_paths`, the same shape. Go's `Glob.files` then normalizes + // to forward slashes immediately before globbing (`fs.Glob(fsys, + // filepath.ToSlash(pattern))`, `config.go:145`) — an absolute Windows entry such as + // `C:\repo\schema.sql` must become `C:/repo/schema.sql` before `legacyPathMatch`/ + // `legacyGlobPattern` (which only recognize `/` as a segment separator) ever see it. + // Mirrors `legacy-seed-ops.ts`'s identical `toSlash` step for `[db.seed] sql_paths`. + // + // Gated on `path.sep !== "/"`, mirroring BOTH `legacyCleanSchemaPath` below AND + // `legacyGlobPattern`'s own internal `path.sep === "/" ? pattern : ...` normalization + // (`legacy-glob.ts:68`) — `filepath.ToSlash` is a byte-for-byte no-op on POSIX (only + // Windows's `filepath.Separator` is `\`), so converting unconditionally here previously + // fed `legacyGlobPattern` an already-slashed pattern on POSIX too, silently discarding + // any `\` a caller wrote as a `path.Match` escape. Verified empirically with a scratch + // `path.Match` probe on darwin: `path.Match("foo\\*.sql", "foo*.sql")` (Go's real, + // unconverted-on-POSIX behavior) is `true` — a literal `\*` escapes the metacharacter, + // matching a file literally named `foo*.sql` — while this file's OLD unconditional + // `.replaceAll("\\", "/")` turned the same pattern into `foo/*.sql`, which instead + // searches a `foo/` subdirectory and never matches the literal `foo*.sql` file Go finds. + // The same probe also caught a second-order bug: unconditionally rewriting `\[` (a valid + // escaped literal `[`) into `/[` turns it into an unterminated character class, so a + // pattern that is well-formed for Go's `path.Match` was spuriously rejected as malformed + // here. Leaving `\` untouched on POSIX lets `legacyPathMatch`'s own escape handling (used + // by both this and `legacyGlobPattern`) reproduce Go's semantics directly — no gap in + // that shared module needs fixing first. + const rawResolved = legacyResolveSeedSqlPath(path, rawPattern); + // Go's `Glob.files` (`config.go:145`) only ever ToSlashes the pattern for the internal + // `fs.Glob` CALL itself — `hasGlobMeta`, the `skipped` slice, and both "no files matched + // pattern" error sites all keep using the loop's own `pattern` variable, which is NEVER + // ToSlash'd (`config.go:143-154`). So on Windows, an absolute entry like + // `C:\schemas\*.sql` must glob-match as `C:/schemas/*.sql` but still ERROR/report as + // `C:\schemas\*.sql` — `matchPattern` (slashed) feeds `legacyPathMatch`/`legacyGlobPattern` + // below; `rawResolved` (untouched) feeds every diagnostic (`skipped`/`problems`) so stderr + // stays byte-compatible with Go's un-ToSlash'd `pattern`. + const matchPattern = path.sep === "/" ? rawResolved : rawResolved.replaceAll("\\", "/"); + if (legacyPathMatch(matchPattern, "").badPattern) { + problems.push(`failed to glob files: ${LEGACY_BAD_PATTERN_MESSAGE}`); + continue; + } + // Go's `io/fs.Glob` never matches an empty pattern: its literal (no-metacharacter) + // branch calls `Stat(fsys, "")`, which fails on a real OS filesystem (there is no file + // whose path is the empty string), so `Glob` returns zero matches — verified empirically + // against the real `config.Glob.SQLFiles` (`apps/cli-go/pkg/config/config.go:119-133`) + // fed pattern `""` against an `afero.NewOsFs()`: it reports `no files matched pattern: `, + // the same as any other non-matching literal pattern. `legacyGlobPattern`'s own + // literal-pattern branch, however, resolves an empty pattern to the WORKDIR itself + // (`legacyResolveUnderWorkdir(path, workdir, "")` is the workdir, which always exists), + // so without this guard an empty `schema_paths` entry would recurse into and collect + // every `.sql` file in the entire project instead of matching nothing. Short-circuit + // before calling it, rather than fixing `legacyGlobPattern` itself, since that shared + // helper (`legacy-glob.ts`) also backs `[db.seed] sql_paths` (`legacy-seed.ts`) and + // `legacy-migrate-and-seed.ts`, both out of scope for this PR. + // Go's `sort.Strings(matches)` (`config.go:154`) — byte order, not JS's default UTF-16 + // code-unit order; see `legacyCompareUtf8Bytes`'s own doc comment. + const matches = + matchPattern.length === 0 + ? [] + : [...(yield* legacyGlobPattern(fs, path, workdir, matchPattern))].sort( + legacyCompareUtf8Bytes, + ); + if (matches.length === 0) { + if (legacyHasConfigGlobMeta(rawResolved)) { + skipped.push(rawResolved); + continue; + } + // Go always resolves `SchemaPaths` (`config.go:976-979`) before this error can fire + // (resolution happens at config-load time, ahead of any glob), so the error must show + // the RESOLVED, `supabase/`-prefixed pattern, matching the all-skipped-globs branch + // below — not the raw, caller-supplied one. Still `rawResolved`, not `matchPattern`: + // see this loop's own doc comment above on why Go's error text is never ToSlash'd. + problems.push(`no files matched pattern: ${rawResolved}`); + continue; + } + for (const match of matches) { + const absMatch = legacyResolveUnderWorkdir(path, workdir, match); + const statResult = yield* fs.stat(absMatch).pipe(Effect.result); + if (Result.isFailure(statResult)) { + problems.push(`failed to stat matched file: ${statResult.failure.message}`); + continue; + } + if (statResult.success.type !== "Directory") { + if (!seen.has(match)) { + seen.add(match); + result.push(match); + } + continue; + } + // Go's `walkMatchedDir` (`pkg/config/config.go:194-211`) propagates ANY `fs.WalkDir` + // error (e.g. a permission-denied or I/O-erroring subdirectory) as `failed to walk + // matched directory: ` — it does NOT treat an unreadable directory as an empty + // match set, since silently doing so can omit declared schemas and compare a + // local-target diff against the wrong target. `legacyWalkRegularSqlFilesNoFollow` also + // matches Go's no-follow-symlink walk semantics — see its doc comment. + const sqlRelativeResult = yield* legacyWalkRegularSqlFilesNoFollow(fs, path, absMatch).pipe( + Effect.result, + ); + if (Result.isFailure(sqlRelativeResult)) { + problems.push(`failed to walk matched directory: ${sqlRelativeResult.failure.message}`); + continue; + } + for (const relative of sqlRelativeResult.success) { + // `io/fs.WalkDir`'s own path.Join(dir, entry.Name()) (`io/fs/walk.go`'s `walkDir`) + // cleans redundant separators before `walkMatchedDir`'s callback ever records the + // child path — so a `match` that retains a trailing separator (e.g. a directory + // `schema_paths` entry configured as `"supabase/schemas/"`) never reaches Go's dedup + // `set` as a double-slashed key. A raw template join skips that implicit clean and + // can let the same file be recorded twice — once here, once via a literal + // `schema_paths` entry for the file itself — bypassing `seen` and double-applying the + // SQL. `legacyCleanSchemaPath` (below) performs the equivalent slash-segment + // collapsing and is reused here rather than duplicated (review: PRRT_kwDOErm0O86XAlIr). + const relativeToWorkdir = legacyCleanSchemaPath(`${match}/${relative}`); + if (!seen.has(relativeToWorkdir)) { + seen.add(relativeToWorkdir); + result.push(relativeToWorkdir); + } + } + } + } + + if (result.length === 0 && skipped.length > 0) { + for (const pattern of skipped) problems.push(`no files matched pattern: ${pattern}`); + } + if (problems.length > 0) { + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ message: problems.join("\n") }), + ); + } + return result; + }); +} + +/** + * Port of Go's `afero.Walk` + regular-`.sql`-file filter + `sort.Strings` (the shared tail of + * both `loadDeclaredSchemas`'s pg-delta-declarative-dir and `SchemasDir` branches, + * `apps/cli-go/internal/db/diff/diff.go:65-76,86-96`). `legacyWalkRegularSqlFilesNoFollow` also + * matches Go's no-follow-symlink walk semantics — see its doc comment. + * + * The walk ROOT itself is checked for being a symlink here, unlike `legacyGlobDeclaredSchemaPaths`'s + * directory branch (Go's `fs.WalkDir`, whose own doc comment says "if root itself is a symbolic + * link, its target will be walked" — so a symlinked `schema_paths` match is deliberately followed, + * matching `legacyWalkRegularSqlFilesNoFollow`'s existing never-checks-its-own-root behavior). + * `afero.Walk` is the opposite: its `Walk(fs, root, walkFn)` entry point `Lstat`s the root BEFORE + * ever calling `walkFn`, so a symlinked root is treated as a non-directory and produces zero files + * silently, never descending into the target — verified against `afero`'s own source + * (`path.go`'s `Walk`/`lstatIfPossible`). The PRECEDING `fs.stat`-based existence check in + * `legacyLoadDeclaredSchemas` (which follows symlinks, matching Go's `afero.DirExists` — also + * `fs.Stat`-based) can't substitute for this: existence and walkability are different checks in + * Go, and only the latter uses `Lstat`. + * + * Paths are joined with the injected `Path` service (not a literal `/` template) so a symlink-free + * result matches Go's own `filepath.Join`-built path on every platform — on Windows this yields + * native backslashes (Go's `afero.Walk` never calls `filepath.ToSlash` on this branch, unlike + * `walkMatchedDir`'s `schema_paths` branch, which does), and `path.join` normalizes ANY `/` + * `legacyWalkRegularSqlFilesNoFollow`'s own relative-path construction produced internally, not + * just the outer `dirRel`/`relative` join (verified: `path.win32.join("supabase/database", + * "sub/dir/file.sql")` returns `"supabase\\database\\sub\\dir\\file.sql"`, not a mixed-separator + * string) — on POSIX this is a no-op (`path.posix.join` is byte-identical to the old template). + * + * `errorPrefix` lets the two callers preserve Go's own DIFFERENT wrapping messages for the same + * walk failure: the pg-delta declarative-dir branch reports `"failed to walk declarative dir: + * %w"` while the `supabase/schemas` fallback reports `"failed to walk dir: %w"` + * (`apps/cli-go/internal/db/diff/diff.go:65-76,86-96` — same walk, genuinely different prefix + * per source), so stderr still identifies which configured source failed. + */ +function legacyWalkSqlFilesSorted( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + dirRel: string, + errorPrefix: string, +): Effect.Effect, LegacyDeclarativeShadowDbError> { + return Effect.gen(function* () { + const dirAbs = legacyResolveUnderWorkdir(path, workdir, dirRel); + const isSymlinkRoot = yield* fs.readLink(dirAbs).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (isSymlinkRoot) return []; + const sqlRelative = yield* legacyWalkRegularSqlFilesNoFollow(fs, path, dirAbs).pipe( + Effect.mapError( + (cause) => + new LegacyDeclarativeShadowDbError({ message: `${errorPrefix}: ${cause.message}` }), + ), + ); + return sqlRelative.map((relative) => path.join(dirRel, relative)); + }); +} + +/** + * Port of Go's `loadDeclaredSchemas` (`apps/cli-go/internal/db/diff/diff.go:52-101`): a + * three-source priority ladder — `db.migrations.schema_paths` (when non-empty) -> + * pg-delta's declarative dir (when `[experimental.pgdelta] enabled` AND the dir exists) -> + * `supabase/schemas` (when it exists) -> `[]`. Each source is `sort.Strings`-ordered. + */ +export function legacyLoadDeclaredSchemas( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + schemaPaths: ReadonlyArray, + pgDelta: LegacyPgDeltaTomlConfig, +): Effect.Effect, LegacyDeclarativeShadowDbError> { + return Effect.gen(function* () { + if (schemaPaths.length > 0) { + return yield* legacyGlobDeclaredSchemaPaths(fs, path, workdir, schemaPaths); + } + if (pgDelta.enabled) { + const declDirRel = legacyResolveDeclarativeDir(path, pgDelta); + const declDirAbs = legacyResolveUnderWorkdir(path, workdir, declDirRel); + // Go's `afero.DirExists` (`diff.go:63`) — a path that exists but is a regular file is + // "not a directory" (`err == nil && exists` is false), not an error, so it falls through + // to the `supabase/schemas` source below rather than being walked as a directory. + const isDeclDir = yield* fs.stat(declDirAbs).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orElseSucceed(() => false), + ); + if (isDeclDir) { + return yield* legacyWalkSqlFilesSorted( + fs, + path, + workdir, + declDirRel, + "failed to walk declarative dir", + ); + } + } + const schemasDirRel = "supabase/schemas"; + const schemasDirAbs = legacyResolveUnderWorkdir(path, workdir, schemasDirRel); + // Same `afero.DirExists` semantics as above (`diff.go:80`): a missing path or a path that + // exists but isn't a directory both resolve to "no declared schemas" (`[]`), not an error — + // only a genuine stat failure (permission denied, I/O error) propagates. + const isSchemasDir = yield* fs.stat(schemasDirAbs).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(false) + : Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: `failed to check schemas: ${cause.message}`, + }), + ), + onSuccess: (info) => Effect.succeed(info.type === "Directory"), + }), + ); + if (!isSchemasDir) return []; + return yield* legacyWalkSqlFilesSorted(fs, path, workdir, schemasDirRel, "failed to walk dir"); + }); +} + +/** + * Windows-only sibling of {@link legacyCleanSchemaPath}'s segment cleaner: the length of the + * leading "volume" a Windows path can carry, mirroring Go's `volumeNameLen` + * (`internal/filepathlite/path_windows.go`) for the two shapes realistic in a `schema_paths` + * config value — a drive letter (`C:...`, length 2) and a UNC share (`//host/share`, length + * through the second separator, Go's `uncLen`). Deliberately does NOT port Go's `\\.\`/`\\?\`/ + * `\??\` device-path branches (`\\.\C:\...`, Root Local Device paths) — not realistic values + * for this field, and porting them would add meaningful complexity for no reachable parity + * benefit. `path` is already backslash-normalized to `/` by the caller. + */ +function legacyWindowsVolumeLen(path: string): number { + if (path.length >= 2 && path[1] === ":") return 2; + if (path.length < 2 || path[0] !== "/" || path[1] !== "/") return 0; + let separators = 0; + for (let i = 2; i < path.length; i++) { + if (path[i] === "/") { + separators++; + if (separators === 2) return i; + } + } + return path.length; +} + +/** + * Go's `cleanSchemaPath` (`apps/cli-go/internal/db/diff/diff.go:117-119`): + * `filepath.ToSlash(filepath.Clean(path))`. `filepath.Clean`/`ToSlash` only treat `\` as a path + * separator on the Windows build of the Go CLI (`filepath.Separator == '\\'` there) — on every + * POSIX build (darwin/linux, what this TS binary stands in for on those hosts) a backslash is + * just a literal filename character that survives untouched. Verified empirically: + * `filepath.ToSlash(filepath.Clean(\`supabase/foo\bar\`))` compiled for `GOOS=darwin` returns + * `supabase/foo\bar`, not `supabase/foo/bar`. Gate the separator-normalization on the host + * platform so this matches whichever Go build this TS binary is standing in for. + * + * On Windows, `filepath.Clean` never cleans INTO a leading volume (`internal/filepathlite/ + * path_windows.go`'s `volumeNameLen`/`Clean`) — a UNC host+share (or a drive letter) survives + * verbatim, including its doubled leading separator for UNC, through `ToSlash`. Split it off + * with {@link legacyWindowsVolumeLen} before the segment-cleanup loop below, which would + * otherwise treat a UNC path's two leading empty segments the same as any other redundant + * separator and collapse `//host/share` down to `/host/share` — verified empirically against + * a standalone extraction of Go's own windows `Clean`/`ToSlash` source, run natively (review: + * PRRT_kwDOErm0O86W2tRk): `filepath.ToSlash(filepath.Clean(\`\\server\share\schemas\`))` + * compiled for `GOOS=windows` returns `//server/share/schemas`, not `/server/share/schemas`. + */ +export function legacyCleanSchemaPath( + rawPath: string, + platform: NodeJS.Platform = process.platform, +): string { + const normalized = platform === "win32" ? rawPath.replaceAll("\\", "/") : rawPath; + const volumeLen = platform === "win32" ? legacyWindowsVolumeLen(normalized) : 0; + const volume = normalized.slice(0, volumeLen); + const remainder = normalized.slice(volumeLen); + // A bare volume with nothing after it (`\\server\share`, or `C:`) — Go's Clean leaves it + // untouched rather than falling into the segment-cleanup loop below (which would otherwise + // turn "no path left" into a bare "." and lose the volume). + if (volumeLen > 0 && remainder === "") return volume; + const isAbsolute = remainder.startsWith("/"); + const out: Array = []; + for (const segment of remainder.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (out.length > 0 && out[out.length - 1] !== "..") out.pop(); + else if (!isAbsolute) out.push(".."); + } else { + out.push(segment); + } + } + const joined = out.join("/"); + if (joined.length === 0) return volume + (isAbsolute ? "/" : "."); + return volume + (isAbsolute ? "/" : "") + joined; +} + +/** + * Port of Go's `shouldApplyDeclarativeWithPgDelta` (`apps/cli-go/internal/db/diff/diff.go: + * 103-115`): `usePgDelta` false -> false; zero `schema_paths` -> true; more than one + * `schema_paths` entry -> false; exactly one entry -> true only when it resolves (Go's + * `config.go:976-979` resolution, matching `legacyResolveSeedSqlPath`) to the SAME cleaned + * path as the effective declarative dir. + */ +export function legacyShouldApplyDeclarativeWithPgDelta( + path: Path.Path, + usePgDelta: boolean, + schemaPaths: ReadonlyArray, + pgDelta: LegacyPgDeltaTomlConfig, + platform: NodeJS.Platform = process.platform, +): boolean { + if (!usePgDelta) return false; + if (schemaPaths.length === 0) return true; + if (schemaPaths.length !== 1) return false; + const resolvedSchema = legacyCleanSchemaPath( + legacyResolveSeedSqlPath(path, schemaPaths[0]!), + platform, + ); + const declDir = legacyCleanSchemaPath(legacyResolveDeclarativeDir(path, pgDelta), platform); + return resolvedSchema === declDir; +} + +/** + * Port of Go's `migrateBaseDatabase` (`apps/cli-go/internal/db/diff/diff.go:261-274`): prints + * the declarative-schema file list, connects to `config` (the shadow's `contrib_regression` + * override), then seeds `migrations` as globals (Go's `migration.SeedGlobals` — no history + * row, no history table, WITHOUT the migra-engine schema files' own transactional/seed + * distinctions {@link legacySeedGlobals} already reproduces for every other caller of it). + */ +function legacyMigrateBaseDatabase( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + config: LegacyPgConnInput, + migrations: ReadonlyArray, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const output = yield* Output; + yield* output.raw("Creating local database from declarative schemas:\n", "stderr"); + const msg = migrations.map((m) => ` • ${legacyBold(m)}`).join("\n"); + yield* output.raw(`${msg}\n`, "stderr"); + + const dbConnection = yield* LegacyDbConnection; + const session = yield* dbConnection + .connect(config, { isLocal: true, dnsResolver: "native" }) + .pipe( + Effect.mapError( + (cause) => new LegacyDeclarativeShadowDbError({ message: cause.message }), + ), + ); + + const absolutePaths = migrations.map((m) => legacyResolveUnderWorkdir(path, workdir, m)); + yield* legacySeedGlobals( + session, + fs, + path, + absolutePaths, + (message) => new LegacyDeclarativeShadowDbError({ message }), + ); + }), + ); +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts new file mode 100644 index 0000000000..3291e51fc9 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts @@ -0,0 +1,776 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "effect"; + +import { + legacyCleanSchemaPath, + legacyLoadDeclaredSchemas, + legacyShouldApplyDeclarativeWithPgDelta, +} from "./legacy-shadow-source.ts"; +import type { LegacyPgDeltaTomlConfig } from "../../../shared/legacy-db-config.toml-read.ts"; + +function pgDelta(overrides: Partial = {}): LegacyPgDeltaTomlConfig { + return { + enabled: false, + declarativeSchemaPath: Option.none(), + formatOptions: Option.none(), + npmVersion: Option.none(), + ...overrides, + }; +} + +function makeWorkdir(): string { + return mkdtempSync(join(tmpdir(), "legacy-shadow-source-")); +} + +// Root bypasses POSIX permission bits, so chmod 000 wouldn't block readdir() there. +const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + +describe("legacyShouldApplyDeclarativeWithPgDelta", () => { + it.effect("is false whenever usePgDelta is false, regardless of schema_paths", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyShouldApplyDeclarativeWithPgDelta(path, false, [], pgDelta())).toBe(false); + expect( + legacyShouldApplyDeclarativeWithPgDelta(path, false, ["schemas/x.sql"], pgDelta()), + ).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("is true when usePgDelta and zero schema_paths are configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, [], pgDelta())).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("is false when more than one schema_paths entry is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect( + legacyShouldApplyDeclarativeWithPgDelta(path, true, ["a.sql", "b.sql"], pgDelta()), + ).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "is true when exactly one schema_paths entry resolves to the effective declarative dir", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["database"], pgDelta())).toBe( + true, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("is false when the single schema_paths entry does not match the declarative dir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["schemas"], pgDelta())).toBe( + false, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("matches a configured (non-default) declarative_schema_path the same way", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/custom-decl") }); + expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["custom-decl"], configured)).toBe( + true, + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "on POSIX, a backslash in schema_paths is a literal character, not a path separator", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + // Go's `filepath.Clean`/`ToSlash` only treat `\` as a separator on a Windows build — + // on darwin/linux it's untouched, so a `foo\bar` schema_paths entry (which + // `legacyResolveSeedSqlPath` joins under `supabase/` unresolved) must NOT be treated + // as equivalent to the slash-separated declarative dir `supabase/foo/bar`. + const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/foo/bar") }); + expect( + legacyShouldApplyDeclarativeWithPgDelta(path, true, ["foo\\bar"], configured, "darwin"), + ).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("on win32, a backslash in schema_paths normalizes as a path separator", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/foo/bar") }); + expect( + legacyShouldApplyDeclarativeWithPgDelta(path, true, ["foo\\bar"], configured, "win32"), + ).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); +}); + +describe("legacyCleanSchemaPath", () => { + // Go's `filepath.Clean` (windows build) never cleans INTO a leading UNC volume — verified + // empirically against a standalone extraction of Go's own windows `internal/filepathlite` + // Clean/ToSlash/volumeNameLen source, run natively: `filepath.ToSlash(filepath.Clean( + // \`\\server\share\schemas\`))` compiled for `GOOS=windows` returns `//server/share/schemas` + // (review: PRRT_kwDOErm0O86W2tRk) — the doubled leading separator is part of the UNC host+ + // share, not a redundant separator to collapse to one. + it("preserves a UNC host+share prefix on win32, matching Go's Clean", () => { + expect(legacyCleanSchemaPath("\\\\server\\share\\schemas", "win32")).toBe( + "//server/share/schemas", + ); + }); + + it("cleans `.`/`..` segments AFTER a UNC prefix without touching the prefix itself", () => { + expect(legacyCleanSchemaPath("\\\\server\\share\\a\\.\\b\\..\\c", "win32")).toBe( + "//server/share/a/c", + ); + }); + + it("drops a leading `..` past a UNC share root instead of climbing above it", () => { + expect(legacyCleanSchemaPath("\\\\server\\share\\..\\schemas", "win32")).toBe( + "//server/share/schemas", + ); + }); + + it("leaves a bare UNC share (no subpath) unchanged", () => { + expect(legacyCleanSchemaPath("\\\\server\\share", "win32")).toBe("//server/share"); + }); + + it("does not confuse a UNC path with the distinct root-relative path of the same tail", () => { + // The bug this guards against: collapsing `//server/share/schemas` down to + // `/server/share/schemas` would make a UNC `schema_paths` entry compare equal to an + // unrelated root-relative declarative dir. + expect(legacyCleanSchemaPath("\\\\server\\share\\schemas", "win32")).not.toBe( + legacyCleanSchemaPath("/server/share/schemas", "win32"), + ); + }); + + it("still cleans a drive-letter path correctly", () => { + expect(legacyCleanSchemaPath("C:\\foo\\..\\bar", "win32")).toBe("C:/bar"); + }); + + it("does not treat a doubled separator as a UNC volume off win32", () => { + // POSIX has no UNC concept — Go's non-Windows `filepath.Clean` collapses redundant + // separators uniformly, same as this function's pre-existing POSIX behavior. + expect(legacyCleanSchemaPath("//server/share/schemas", "darwin")).toBe("/server/share/schemas"); + }); +}); + +describe("legacyLoadDeclaredSchemas", () => { + it.effect( + "returns [] when neither schema_paths, an enabled pg-delta dir, nor supabase/schemas exist", + () => { + const workdir = makeWorkdir(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "falls back to sorted supabase/schemas/*.sql when no schema_paths/pg-delta dir apply", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "b.sql"), "select 2;\n"); + writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual(["supabase/schemas/a.sql", "supabase/schemas/b.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "prefers the pg-delta declarative dir over supabase/schemas when pg-delta is enabled and the dir exists", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "database"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "database", "t.sql"), "select 1;\n"); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "unused.sql"), "select 2;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ enabled: true }), + ); + expect(result).toEqual(["supabase/database/t.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "prefers db.migrations.schema_paths over both the pg-delta dir and supabase/schemas", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); + mkdirSync(join(workdir, "supabase", "database"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "database", "unused.sql"), "select 2;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom/*.sql"], + pgDelta({ enabled: true }), + ); + expect(result).toEqual(["supabase/custom/x.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("fails when a literal (non-glob) schema_paths entry matches nothing", () => { + const workdir = makeWorkdir(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["missing.sql"], + pgDelta(), + ).pipe(Effect.exit); + expect(exit._tag).toBe("Failure"); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + 'an empty schema_paths entry matches nothing, not the entire project (Go\'s fs.Glob(""))', + () => { + // Go's `io/fs.Glob` never matches an empty pattern — its literal-pattern branch calls + // `Stat(fsys, "")`, which fails on a real OS filesystem, so `Glob.SQLFiles` reports + // `no files matched pattern: ` for it (verified empirically against the real + // `config.Glob.SQLFiles` fed `""` over an `afero.NewOsFs()`). Without this guard, + // `legacyGlobPattern`'s literal-pattern branch resolves `""` to the workdir itself + // (which always exists) and recursively collects every `.sql` file in the project, + // including files well outside any declared schema path. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "migrations", "001_init.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [""], pgDelta()).pipe( + Effect.exit, + ); + expect(exit._tag).toBe("Failure"); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("a glob schema_paths entry matching nothing is silently skipped, not an error", () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom/*.sql", "empty-glob/*.sql"], + pgDelta(), + ); + expect(result).toEqual(["supabase/custom/x.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + "on POSIX, a backslash in a schema_paths entry is a path.Match escape, not a separator (review: PRRT_kwDOErm0O86W7n90)", + () => { + // Go's `filepath.ToSlash` (`fs.Glob(fsys, filepath.ToSlash(pattern))`, + // `pkg/config/config.go:145`) is a byte-for-byte no-op on POSIX — only Windows's + // `filepath.Separator` is `\`. `path.Match` (what `fs.Glob` compiles down to) then + // treats an un-converted `\` as an escape metacharacter: `custom\x.sql` escapes the + // literal `x`, matching a FILE literally named `customx.sql` directly under + // `supabase/`, never the path-separated `supabase/custom/x.sql`. Verified empirically: + // `path.Match("custom\\x.sql", "customx.sql")` is `true` on darwin, while + // `path.Match("custom\\x.sql", "custom/x.sql")` never even reaches that filename (the + // pattern has no `/`, so it only lists `supabase/`, never descends into `custom/`). + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "customx.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom\\x.sql"], + pgDelta(), + ); + expect(result).toEqual(["supabase/customx.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "on POSIX, a backslash-escaped glob metacharacter in schema_paths matches the literal filename (review: PRRT_kwDOErm0O86W7n90)", + () => { + // The specific case the review thread flagged: `path.Match("foo\\*.sql", "foo*.sql")` + // is `true` on darwin — the escaped `*` is a literal asterisk, matching a file named + // `foo*.sql`, not a glob that searches a `foo/` subdirectory. Before this fix, + // `legacyGlobDeclaredSchemaPaths` unconditionally rewrote the pattern to `foo/*.sql` + // ahead of globbing, which searches `foo/` instead and would report "no files matched" + // for this exact, valid Go config. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "foo*.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["foo\\*.sql"], + pgDelta(), + ); + expect(result).toEqual(["supabase/foo*.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "dedupes a directory schema_paths entry with a trailing separator against a literal-file entry for the same file (review: PRRT_kwDOErm0O86XAlIr)", + () => { + // A RELATIVE trailing-slash entry gets `path.Join`-cleaned away by + // `legacyResolveSeedSqlPath` before it ever reaches the glob, matching Go's own + // `path.Join(builder.SupabaseDirPath, pattern)` resolution — so the bug is only + // reachable via an ABSOLUTE entry, which `legacyResolveSeedSqlPath` returns verbatim + // (Go's `Glob.files` never resolves an absolute entry either). Without the fix, the + // directory branch recorded the walked file as `/custom//x.sql` (raw template + // concatenation), which never matches the literal entry's `/custom/x.sql` in + // `seen`, so both were appended to `result` and the declarative apply would run the + // same file's SQL twice. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); + const absDirWithTrailingSlash = `${join(workdir, "supabase", "custom")}/`; + const absFile = join(workdir, "supabase", "custom", "x.sql"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [absDirWithTrailingSlash, absFile], + pgDelta(), + ); + expect(result).toEqual([absFile]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "excludes a symlinked .sql file from a recursively-matched schema_paths directory", + () => { + // Go's `entry.Type().IsRegular()` (`config.go:127`) is a no-follow check — a symlink + // is never "regular", so `walkMatchedDir` excludes it even when it resolves to a real + // `.sql` file. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "real.sql"), "select 1;\n"); + const secretTarget = join(workdir, "outside.sql"); + writeFileSync(secretTarget, "select 2;\n"); + symlinkSync(secretTarget, join(workdir, "supabase", "custom", "linked.sql")); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()); + expect(result).toEqual(["supabase/custom/real.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("excludes a symlinked .sql file from the supabase/schemas fallback walk", () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "real.sql"), "select 1;\n"); + const secretTarget = join(workdir, "outside.sql"); + writeFileSync(secretTarget, "select 2;\n"); + symlinkSync(secretTarget, join(workdir, "supabase", "schemas", "linked.sql")); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual(["supabase/schemas/real.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + "does not follow a symlinked subdirectory in a recursively-matched schema_paths directory", + () => { + // Go's `fs.WalkDir` (`walkMatchedDir`, `config.go:194-211`) is `Lstat`-based and never + // descends into a symlinked directory (`io/fs.WalkDir` doc: "WalkDir does not follow + // symbolic links found in directories") — a schema dir symlinking OUT of the configured + // schema tree must not leak the linked directory's files into the diff/pull target. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "custom", "real.sql"), "select 1;\n"); + const outsideDir = join(workdir, "outside"); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(outsideDir, "secret.sql"), "select 2;\n"); + symlinkSync(outsideDir, join(workdir, "supabase", "custom", "linked-dir"), "dir"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()); + expect(result).toEqual(["supabase/custom/real.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "does not follow a symlinked subdirectory in the supabase/schemas fallback walk", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "real.sql"), "select 1;\n"); + const outsideDir = join(workdir, "outside"); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(outsideDir, "secret.sql"), "select 2;\n"); + symlinkSync(outsideDir, join(workdir, "supabase", "schemas", "linked-dir"), "dir"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual(["supabase/schemas/real.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "falls back to supabase/schemas when the pg-delta declarative path exists but is a regular file", + () => { + // Go's `afero.DirExists` (`apps/cli-go/internal/db/diff/diff.go:63`) treats a non-directory + // path as absent, not present-but-unwalkable — a stray `supabase/database` FILE (e.g. left + // over from a previous config) must fall through to `supabase/schemas`, not make + // `legacyWalkSqlFilesSorted` try (and fail) to read a file as a directory. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "database"), "not a directory"); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ enabled: true }), + ); + expect(result).toEqual(["supabase/schemas/a.sql"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "returns [] when supabase/schemas exists but is a regular file, not a directory", + () => { + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas"), "not a directory"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "returns [] (does not follow) when the pg-delta declarative dir itself is a symlink", + () => { + // Go's `afero.Walk(fsys, declDir, ...)` Lstat's the ROOT before ever calling `walkFn` + // (`afero`'s own `Walk`/`lstatIfPossible`) — a symlinked root is treated as a + // non-directory and produces zero files, silently, never descending into the target. + // The PRECEDING `afero.DirExists`-equivalent existence check (which follows symlinks, + // matching Go's own `fs.Stat`-based `DirExists`) reports the symlinked dir as present, so + // only the WALK itself (not the existence check) must reject it. + const workdir = makeWorkdir(); + const realDir = join(workdir, "real-database"); + mkdirSync(realDir, { recursive: true }); + writeFileSync(join(realDir, "t.sql"), "select 1;\n"); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + symlinkSync(realDir, join(workdir, "supabase", "database"), "dir"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ enabled: true }), + ); + expect(result).toEqual([]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("returns [] (does not follow) when supabase/schemas itself is a symlink", () => { + const workdir = makeWorkdir(); + const realDir = join(workdir, "real-schemas"); + mkdirSync(realDir, { recursive: true }); + writeFileSync(join(realDir, "t.sql"), "select 1;\n"); + mkdirSync(join(workdir, "supabase"), { recursive: true }); + symlinkSync(realDir, join(workdir, "supabase", "schemas"), "dir"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + "sorts declared schema paths by UTF-8 byte order, not JS's default UTF-16 code-unit order", + () => { + // A supplementary-plane character (U+1F600, a surrogate pair in UTF-16) alongside a BMP + // private-use character (U+E000) is the textbook case where JS's default `.sort()` + // (UTF-16 code units) disagrees with Go's `sort.Strings` (UTF-8 bytes, which preserves + // codepoint order): JS ranks the surrogate pair first (0xD800 < 0xE000), Go ranks the + // supplementary-plane codepoint last (it's numerically > U+FFFF). Verified empirically + // against `Buffer.compare` on the two filenames' UTF-8 encodings. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + const supplementary = "a\u{1F600}.sql"; + const privateUse = "a.sql"; + writeFileSync(join(workdir, "supabase", "schemas", supplementary), "select 1;\n"); + writeFileSync(join(workdir, "supabase", "schemas", privateUse), "select 2;\n"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([ + `supabase/schemas/${privateUse}`, + `supabase/schemas/${supplementary}`, + ]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "propagates (rather than silently drops) a per-entry stat failure during the pg-delta/schemas walk", + () => { + // Both Go walkers (`afero.Walk`, `fs.WalkDir`) pass a per-entry stat/lstat error to their + // callback, which returns it and aborts the whole walk — an entry that can't be statted + // after its parent was listed (permissions, I/O error, a concurrent filesystem change) + // must not be silently omitted, which could build an incomplete declarative target. + const workdir = makeWorkdir(); + mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); + const brokenAbs = join(workdir, "supabase", "schemas", "broken.sql"); + writeFileSync(brokenAbs, "select 2;\n"); + const statFs = Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (real) => ({ + ...real, + stat: (statPath: string) => + statPath === brokenAbs + ? Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "stat", + description: "simulated stat failure", + pathOrDescriptor: statPath, + }), + ) + : real.stat(statPath), + })), + ).pipe(Layer.provideMerge(BunServices.layer)); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe( + Effect.exit, + ); + expect(exit._tag).toBe("Failure"); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(statFs)); + }, + ); + + it.effect.skipIf(isRoot)( + "fails (rather than silently treating as empty) when a matched schema directory can't be read, and keeps the underlying cause in the message", + () => { + // Go's `walkMatchedDir` (`pkg/config/config.go:194-211`) propagates ANY `fs.WalkDir` + // error as `failed to walk matched directory: ` — an unreadable directory must + // surface as a failure, not silently contribute zero files (which could compare a + // local-target diff against the wrong target or generate an incomplete migration), and + // the reported message must carry the real underlying error (permission denied, here), + // not just the directory name — otherwise a user can't tell WHY the walk failed. + const workdir = makeWorkdir(); + const locked = join(workdir, "supabase", "locked"); + mkdirSync(locked, { recursive: true }); + chmodSync(locked, 0o000); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["locked"], + pgDelta(), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("failed to walk matched directory:"); + expect(errorJson).not.toContain("failed to walk matched directory: locked"); + } + chmodSync(locked, 0o755); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect.skipIf(isRoot)( + "visits sibling directories in UTF-8 byte order, not JS's default UTF-16 order, so the reported failure matches Go's (review: PRRT_kwDOErm0O86XAlIo)", + () => { + // `["dir\u{1F600}", "dir\u{E000}"].sort()` (JS default, UTF-16 code-unit order) puts the + // supplementary-plane name FIRST — its lead surrogate (0xD83D) is less than the + // private-use code unit (0xE000). Byte order (Go's `sort.Strings`/`bytealg.CompareString`, + // what `legacyCompareUtf8Bytes` reproduces) disagrees: U+1F600 encodes to a LARGER first + // UTF-8 byte (0xF0) than U+E000 (0xEE), so the private-use name sorts first instead. + // Both subdirectories are unreadable, so whichever the walk visits FIRST is the one whose + // `EACCES` failure aborts the whole walk (Effect.gen never reaches the second entry) — + // its path, not the other one's, must appear in the resulting error. + const workdir = makeWorkdir(); + const matched = join(workdir, "supabase", "custom"); + const utf16First = join(matched, "dir\u{1F600}"); + const byteOrderFirst = join(matched, "dir\u{E000}"); + mkdirSync(utf16First, { recursive: true }); + mkdirSync(byteOrderFirst, { recursive: true }); + chmodSync(utf16First, 0o000); + chmodSync(byteOrderFirst, 0o000); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom"], + pgDelta(), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain(byteOrderFirst); + expect(errorJson).not.toContain(utf16First); + } + chmodSync(utf16First, 0o755); + chmodSync(byteOrderFirst, 0o755); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect.skipIf(isRoot)( + "reports the pg-delta declarative dir walk failure as 'failed to walk declarative dir', not the generic 'failed to walk dir'", + () => { + // Go's `loadDeclaredSchemas` (`apps/cli-go/internal/db/diff/diff.go:52-101`) wraps the + // SAME `afero.Walk` failure with a DIFFERENT prefix per source: the pg-delta declarative + // dir branch reports `failed to walk declarative dir: %w`, while the `supabase/schemas` + // fallback (covered by the sibling test below) reports `failed to walk dir: %w` — both + // walks share `legacyWalkSqlFilesSorted`, which must be told which source it's walking. + const workdir = makeWorkdir(); + const declDir = join(workdir, "supabase", "database"); + mkdirSync(declDir, { recursive: true }); + chmodSync(declDir, 0o000); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ enabled: true }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("failed to walk declarative dir:"); + expect(errorJson).not.toContain("failed to walk dir:"); + } + chmodSync(declDir, 0o755); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect.skipIf(isRoot)( + "reports the supabase/schemas fallback walk failure as 'failed to walk dir', not the declarative-dir prefix", + () => { + const workdir = makeWorkdir(); + const schemasDir = join(workdir, "supabase", "schemas"); + mkdirSync(schemasDir, { recursive: true }); + chmodSync(schemasDir, 0o000); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("failed to walk dir:"); + expect(errorJson).not.toContain("failed to walk declarative dir:"); + } + chmodSync(schemasDir, 0o755); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/start/start.handler.ts b/apps/cli/src/legacy/commands/db/start/start.handler.ts index 866993c135..48561aacba 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -3,21 +3,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { - LegacyDebugFlag, - LegacyNetworkIdFlag, - legacyResolveExperimentalWithProjectEnv, -} from "../../../../shared/legacy/global-flags.ts"; +import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyIsBitbucketPipeline } from "../../../shared/legacy-bitbucket-pipeline.ts"; import { legacyCheckDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; -import { - legacyCliProjectFilterValue, - legacyResolveNetworkId, - localDbContainerId, -} from "../../../shared/legacy-docker-ids.ts"; import { legacyEnvOverride, legacyEnvOverrideApiMaxRows, @@ -34,7 +24,6 @@ import { legacyResolveAuthEmail, legacyResolveAuthEmailSmtp, legacyResolveAuthExternalProviders, - legacyResolveAuthExternalUrl, legacyResolveAuthHooks, legacyResolveAuthMfa, legacyResolveAuthSms, @@ -45,7 +34,6 @@ import { legacyResolveGotrueSessions, legacyResolveGotrueWeb3, legacyResolveLocalConfigValues, - legacyResolveLocalJwks, legacyResolveThirdPartyProviders, } from "../../../shared/legacy-local-config-values.ts"; import { @@ -55,12 +43,11 @@ import { import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import { legacyGoUrlParse } from "../../../shared/legacy-storage-url.ts"; import { legacyLoadLocalProjectContext } from "../../../shared/legacy-local-project-context.ts"; -import { legacyResolveDbBootstrapConfig } from "../../../shared/db-bootstrap/bootstrap-config.ts"; -import { legacyEnsureImagesCached } from "../../../shared/db-bootstrap/image-prepull.ts"; +import { legacyCliProjectFilterValue } from "../../../shared/legacy-docker-ids.ts"; +import { legacyBuildLocalDbContainerInputs } from "../../../shared/db-bootstrap/local-container-inputs.ts"; import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; import { legacyRollbackStart } from "../../../shared/db-bootstrap/rollback.ts"; import { legacyStartDatabase } from "../../../shared/db-bootstrap/start-database.ts"; -import type { LegacyContainerOpts } from "../../../shared/db-bootstrap/container-lifecycle.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; function asRecord(value: unknown): Record | undefined { @@ -125,6 +112,12 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega const runtimeInfo = yield* RuntimeInfo; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const networkIdFlag = yield* LegacyNetworkIdFlag; + // Threaded into `legacyRollbackStart`'s own `legacyDockerRemoveAll` teardown — Go's + // `--debug` gates that function's `Pruned …:` stderr reports (`docker.go:123-143`, + // `viper.GetBool("DEBUG")`), matching `supabase start`'s own handler — and into + // `legacyBuildLocalDbContainerInputs`'s own `setup.debug`, so a failed fresh-volume + // Realtime/Storage/Auth migrate job tees its own stderr (`db-setup.ts`'s + // `legacyRunStartMigrateJob`). const debug = yield* LegacyDebugFlag; const body = Effect.gen(function* () { @@ -168,7 +161,13 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega cliConfig.workdir, (message) => new LegacyDbConfigLoadError({ message }), ); - const { config, projectEnvValues, loaded, hostname, projectId } = context; + // `projectId`/`hostname` are NOT destructured under their bare names here — the not-running + // branch below reloads its OWN (identical) copy of the whole context via + // `legacyBuildLocalDbContainerInputs`, which returns its own `context.projectId`/ + // `context.hostname`, and re-declaring those names in this same scope would collide. + // `hostnameForValidation` is still needed here, for the eager, discarded + // `legacyResolveLocalConfigValues` call further down. + const { config, projectEnvValues, loaded, hostname: hostnameForValidation } = context; // Go decodes every `time.Duration` config field — including these 5 — in the same single, // unconditional `Config.Load` pass (`mapstructure.StringToTimeDurationHookFunc()`, @@ -883,18 +882,15 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega } // Closes an entire recurring class of gaps in the battery above, rather than adding another - // one-off field check: `legacyResolveLocalConfigValues` (below) is the SAME resolver this - // handler already calls, unconditionally, to build `values` for the not-running branch — it - // was simply called too LATE, after the already-running shortcut, so every Go `Config.Load`/ - // `Validate` step it performs internally (not just the ones this battery separately - // hand-duplicates above) was skipped whenever Postgres was already up. Moving the SAME call - // here — before the shortcut, matching every other check in this battery — closes 6 review - // findings at once, because they're all steps this one resolver already performs internally: - // `auth.captcha` decode (`legacyResolveAuthCaptcha`, review: PRRT_kwDOErm0O86WYMj_), - // `auth.jwt_secret` length validation (`resolveJwtSecret`/`generateAPIKeys`, review: - // PRRT_kwDOErm0O86WYMkJ), `auth.signing_keys_path` file read - // (`legacyResolveConfiguredSigningKeys`, review: PRRT_kwDOErm0O86WYMkM), `api.tls` cert/key - // path validation + file reads (`readApiTlsFiles`, review: PRRT_kwDOErm0O86WYMkP), + // one-off field check: `legacyResolveLocalConfigValues` is the SAME resolver + // `legacyBuildLocalDbContainerInputs` calls again below, in the not-running branch, to build + // the REAL `values` the container bring-up needs — calling it EAGERLY here too, before the + // already-running shortcut, closes 6 review findings at once, because they're all steps this + // one resolver already performs internally: `auth.captcha` decode (`legacyResolveAuthCaptcha`, + // review: PRRT_kwDOErm0O86WYMj_), `auth.jwt_secret` length validation + // (`resolveJwtSecret`/`generateAPIKeys`, review: PRRT_kwDOErm0O86WYMkJ), `auth.signing_keys_path` + // file read (`legacyResolveConfiguredSigningKeys`, review: PRRT_kwDOErm0O86WYMkM), `api.tls` + // cert/key path validation + file reads (`readApiTlsFiles`, review: PRRT_kwDOErm0O86WYMkP), // `auth.external.*` required-field validation (`validateAuthExternalProviders`, review: // PRRT_kwDOErm0O86WYMkT), and `auth.email`/notification template content reads // (`readAuthEmailTemplateContent`, review: PRRT_kwDOErm0O86WYMkW) — all genuinely unconditional @@ -906,17 +902,15 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // `db.ssl_enforcement.enabled`, `db.health_timeout`, `storage.{enabled,file_size_limit}`, // Mailpit/Logflare's non-primary ports) — `status`/`stop` never read those either, so // `legacyResolveLocalConfigValues` never decodes them, and they still need their own eager - // check the same way they always have. It's harmless (not incorrect) that this also - // re-validates a handful of fields the battery above already covers one-by-one (e.g. - // `studio.port`/`local_smtp.port`, `api.tls.enabled`) — same "resolve once, still call again - // to force the decode" precedent `db.settings`/`realtime.*` already use elsewhere in this - // battery — removing those now-redundant individual checks is a separate cleanup, not required - // to close the gaps above. - const values = yield* Effect.try({ + // check the same way they always have. Its result is discarded here — only the fail-fast + // behavior matters — and `legacyBuildLocalDbContainerInputs` below re-resolves the REAL + // `values`, same "resolve once, still call again to force the decode" precedent + // `db.settings`/`realtime.*` already use elsewhere in this battery. + yield* Effect.try({ try: () => legacyResolveLocalConfigValues( config, - hostname, + hostnameForValidation, cliConfig.workdir, projectEnvValues, loaded?.document, @@ -967,49 +961,28 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega // resolver) plus the shared `legacyResolveDbBootstrapConfig` derivation `supabase // start` also uses — deliberately narrower than `supabase start`'s own prelude: no // `--exclude`, no image pre-pull for any other service, no JWT/JWKS/image resolution - // beyond what Postgres and its own fresh-volume setup jobs need. - // Go's `viper.GetBool("EXPERIMENTAL")` (`internal/migration/apply/apply.go:19`), read deep - // inside `legacyStartDatabase`'s fresh-volume setup pipeline — resolved here (project `.env` - // aware, like `db reset`'s identical gate) so it can be threaded straight through. - const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnvValues); - - // `values` was already resolved above, before the already-running shortcut (see that call's - // own doc comment) — reused here rather than calling `legacyResolveLocalConfigValues` a - // second time. - const bootstrapConfig = yield* legacyResolveDbBootstrapConfig( - fs, - path, - { config, projectEnvValues, workdir: cliConfig.workdir }, - (message) => new LegacyDbConfigLoadError({ message }), - ); - - // Go's `DockerStart` forces every container's network mode (and the network it creates) - // to `--network-id` when set, ahead of the generated `supabase_network_` fallback - // (`docker.go:379-383`) — and `--network-id` falls back to the `SUPABASE_NETWORK_ID` - // shell/project-dotenv env var when the flag itself is omitted, via the same - // `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` (review: - // PRRT_kwDOErm0O86VlqIL; see {@link legacyResolveNetworkId}'s doc comment for why this is NOT - // the same freeze-at-package-init shape as `utils.Config.Hostname`). - const networkId = legacyResolveNetworkId( - Option.getOrUndefined(networkIdFlag), - projectId, - projectEnvValues, - ); - // Go's `DockerStart` unconditionally appends the Linux-only - // `host.docker.internal:host-gateway` extra host for every container it starts - // (`docker_linux.go`; empty on darwin/windows, where Docker Desktop already resolves that - // hostname). - const extraHosts = - runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - const isBitbucketPipeline = legacyIsBitbucketPipeline(); - const startOpts: LegacyContainerOpts = { - projectId, - isBitbucketPipeline, - workdir: cliConfig.workdir, - extraHosts, - }; + // beyond what Postgres and its own fresh-volume setup jobs need. Shared with `db reset`'s + // own identical prelude — see `legacyBuildLocalDbContainerInputs`'s own header for why + // `fromBackup`/rollback tracking stay here instead of moving into it. + const inputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + cliConfig.workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + ); + const { + context: { projectId, hostname }, + values, + bootstrapConfig, + networkId, + containerOpts, + dbContainerId, + postgresSpecBase, + resolvePostgresImage, + setup, + } = inputs; - const dbContainerId = localDbContainerId(projectId); const filterValue = legacyCliProjectFilterValue(projectId); // Go's `utils.NoBackupVolume` package var — assigned by `legacyStartDatabase`'s own @@ -1033,96 +1006,25 @@ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: Lega hostname, dbContainerId, dbPort: values.dbPort, - containerOpts: startOpts, - postgresSpec: { - db: { - ...config.db, - port: values.dbPort, - major_version: bootstrapConfig.majorVersion, - settings: legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), - }, - experimental: { - ...config.experimental, - orioledb_version: bootstrapConfig.orioledbVersion, - s3_host: bootstrapConfig.s3Host, - s3_region: bootstrapConfig.s3Region, - s3_access_key: bootstrapConfig.s3AccessKey, - s3_secret_key: bootstrapConfig.s3SecretKey, - }, - jwtSecret: values.jwtSecret, - jwtExpiry: values.authJwtExpiry, - projectId, - networkId, - configImage: bootstrapConfig.postgresImage, - rootKey: values.rootKey, - fromBackup, - }, + containerOpts, + // `fromBackup` (if set) drives BOTH the restore-entrypoint variant and + // `legacyStartDatabase`'s own backup-volume-exists guard — `db reset` has no + // `fromBackup` concept at all, so `postgresSpecBase` omits it. + postgresSpec: { ...postgresSpecBase, fromBackup }, // Go's `db start` never pre-pulls any OTHER service's image (it has no // `ensureImagesCached`-equivalent pre-pull pass at all — `internal/start/start.go`'s own // pre-pull is top-level-`start`-only) — only the `db` container's own image, resolved // lazily, right where Go's `DockerStart` would resolve it internally // (`DockerResolveImageIfNotCached`, `internal/utils/docker.go:363-365`). - resolvePostgresImage: legacyEnsureImagesCached( - spawner, - [bootstrapConfig.postgresImage], - projectEnvValues, - ).pipe( - Effect.map( - (resolved) => - resolved.get(bootstrapConfig.postgresImage) ?? bootstrapConfig.postgresImage, - ), - ), + resolvePostgresImage, dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, - setup: { - majorVersion: bootstrapConfig.majorVersion, - experimental, - config: { - ...config, - realtime: { - ...config.realtime, - enabled: bootstrapConfig.realtimeEnabledForSetup, - ip_version: bootstrapConfig.realtimeIpVersion, - max_header_length: bootstrapConfig.realtimeMaxHeaderLength, - }, - storage: { - ...config.storage, - enabled: bootstrapConfig.storageEnabledForSetup, - file_size_limit: bootstrapConfig.storageFileSizeLimit, - }, - auth: { - ...config.auth, - enabled: bootstrapConfig.authEnabledForSetup, - }, - }, - dbUrl: values.dbUrl, - jwtSecret: values.jwtSecret, - // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on - // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — unlike `supabase - // start`'s OWN unconditional, up-front `ResolveJWKS` call (which also feeds the - // long-running Realtime/GoTrue/PostgREST containers `db start` never creates). - // `legacyStartDatabase` only evaluates this Effect when reached AND - // `realtimeEnabledForSetup` — see its own header for why this is lazy. - jwks: Effect.tryPromise({ - try: () => - legacyResolveLocalJwks(config, cliConfig.workdir, values.jwtSecret, projectEnvValues), - catch: (cause) => - new LegacyDbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }), - apiUrl: values.apiUrl, - authExternalUrl: legacyResolveAuthExternalUrl(loaded?.document, projectEnvValues), - siteUrl: values.authSiteUrl, - anonKey: values.anonKey, - serviceRoleKey: values.serviceRoleKey, - storageTargetMigration: bootstrapConfig.storageTargetMigration, - realtimeEnabledForSetup: bootstrapConfig.realtimeEnabledForSetup, - storageEnabledForSetup: bootstrapConfig.storageEnabledForSetup, - authEnabledForSetup: bootstrapConfig.authEnabledForSetup, - serviceVersionOverrides: bootstrapConfig.serviceVersionOverrides, - projectEnvValues, - debug, - }, + // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on + // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — unlike `supabase + // start`'s OWN unconditional, up-front `ResolveJWKS` call (which also feeds the + // long-running Realtime/GoTrue/PostgREST containers `db start` never creates). + // `legacyStartDatabase` only evaluates this Effect when reached AND + // `realtimeEnabledForSetup` — see its own header for why this is lazy. + setup, onFreshVolumeResolved: (resolved) => { isFreshVolume = resolved; }, diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index 506dde00a6..37a9ef6eb7 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -736,6 +736,31 @@ describe("legacy db start", () => { }); }); + it.live( + "an explicitly empty --network-id falls back to the generated network name, not a literal empty override", + () => { + // Go's gate is `len(viper.GetString("network-id")) > 0` (docker.go:379-383), not merely + // "the flag was passed" — an empty override (e.g. a shell expanding an unset var to "") + // must fall through to the generated `supabase_network_` name, not produce a + // literal `--network ""` on the `docker create` call. + const { layer, child } = setup({ + route: freshVolumeRoute(defaultRoute()), + networkId: "", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect( + child.spawned.some( + (s) => s.args[0] === "network" && s.args.at(-1) === "supabase_network_test", + ), + ).toBe(true); + const args = createArgs(child.spawned); + const networkIndex = args?.indexOf("--network") ?? -1; + expect(args?.[networkIndex + 1]).toBe("supabase_network_test"); + }); + }, + ); + it.live( "fails with a typed config error on a malformed SUPABASE_DB_HEALTH_TIMEOUT, before any container is created", () => { diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index 865a9c932e..6bf828e55c 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -61,14 +61,15 @@ type RouteResult = { * step-array mock `gen types` uses for its single linear pipeline. * * `stop`'s single `ps` listing uses the combined `--format "{{.ID}}\t{{.Names}}\t{{.Label - * \"com.supabase.cli.workdir\"}}"` (via `legacyDockerRemoveAll`'s `onContainersRemoved` - * hook, see that function's doc comment) so `legacyCleanupStartSecrets` gets container - * names/workdirs from the same request that lists ids to stop, rather than a second, - * separately-formatted `docker ps` call — which would cost an extra real Docker Engine - * API request Go never makes. `stdout` for a `ps` route response is one `\t` - * line per container (no third, workdir column — every test here exercises the - * `cliConfig.workdir` fallback path); `defaultRoute` below tab-joins each configured id - * with itself. + * \"com.supabase.cli.workdir\"}}\t{{.Label \"com.supabase.cli.secret-dir\"}}"` (via + * `legacyDockerRemoveAll`'s `onContainersRemoved` hook, see that function's doc comment) + * so `legacyCleanupStartSecrets` gets container names/workdirs/secret-dir ids from the + * same request that lists ids to stop, rather than a second, separately-formatted `docker + * ps` call — which would cost an extra real Docker Engine API request Go never makes. + * `stdout` for a `ps` route response is one `\t` line per container (no third or + * fourth, workdir/secret-dir column — every test here exercises the `cliConfig.workdir` + * fallback path and no test container is an unnamed shadow database); `defaultRoute` below + * tab-joins each configured id with itself. */ function mockRoutedContainerCliSpawner( route: (args: ReadonlyArray) => RouteResult, @@ -234,7 +235,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project=demo", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); const stopCalls = child.spawned.filter((s) => s.args[0] === "stop"); expect(stopCalls.map((s) => s.args)).toEqual([ @@ -350,7 +351,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project=My_App_", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe(Effect.provide(layer)); }, @@ -370,7 +371,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project=Raw Value!!", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe(Effect.provide(layer)); }); @@ -386,7 +387,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); const pruneCalls = child.spawned.filter( (s) => s.args[0] === "container" && s.args[1] === "prune", @@ -427,7 +428,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project=other-project", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe(Effect.provide(layer)); }); @@ -446,7 +447,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project=demo", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe(Effect.provide(layer)); }); @@ -467,7 +468,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project=env-file-project", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe(Effect.provide(layer)); }); @@ -485,7 +486,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project=ambient-project", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe( Effect.provide(layer), @@ -520,7 +521,7 @@ describe("legacy stop integration", () => { `label=com.supabase.cli.project=${projectId}`, "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe(Effect.provide(layer)); }, @@ -542,7 +543,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project=no-config-project", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe(Effect.provide(layer)); }); @@ -562,7 +563,7 @@ describe("legacy stop integration", () => { "label=com.supabase.cli.project=root-env-project", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe(Effect.provide(layer)); }); @@ -841,7 +842,7 @@ additional_redirect_urls = "http://a,http://b" "label=com.supabase.cli.project=demo", "--all", "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', ]); }).pipe(Effect.provide(layer)); }, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts b/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts index 0df16a5968..6c15db0b72 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/bootstrap-config.ts @@ -33,6 +33,7 @@ import { legacyEnvOverrideMajorVersion, legacyEnvOverrideRealtimeIpVersion, legacyEnvOverrideRealtimeMaxHeaderLength, + LegacyInvalidRealtimeIpVersionEnvOverrideError, } from "../legacy-local-config-values.ts"; import { legacyReadServiceVersionOverrides } from "../legacy-service-version-overrides.ts"; import { ramInBytes } from "../legacy-size-units.ts"; @@ -42,6 +43,18 @@ export interface LegacyDbBootstrapConfigInput { readonly config: ProjectConfig; readonly projectEnvValues: Readonly> | undefined; readonly workdir: string; + /** + * Config keys a matched `[remotes.]` block contributed at viper's OVERRIDE tier + * (Go's `v.Set`, applied ABOVE `AutomaticEnv` — `apps/cli-go/pkg/config/config.go: + * 635-640`) — see `legacy-db-config.toml-read.ts`'s `LegacyRemoteOverride. + * remoteOverrideKeys` doc comment for the full precedence rationale. Every + * `legacyEnvOverride*` call below must NOT re-apply a `SUPABASE_*` value for a field + * the remote block already set. Defaults to empty: `db start`/`db reset` never resolve + * a remote block for this config read (see `legacyBuildLocalDbContainerInputs`'s own + * doc comment), so they're unaffected; `db diff --linked`/`db pull` (CLI-1956) pass + * the set their sibling `legacyReadDbToml` call already computed. + */ + readonly remoteOverrideKeys?: ReadonlySet; } export interface LegacyDbBootstrapConfig { @@ -107,41 +120,59 @@ export const legacyResolveDbBootstrapConfig = ( ): Effect.Effect => Effect.gen(function* () { const { config, projectEnvValues, workdir } = input; + const remoteOverrideKeys = input.remoteOverrideKeys ?? new Set(); + const remoteWins = (dottedFieldPath: string): boolean => + remoteOverrideKeys.has(dottedFieldPath); // Go's `Config.Load` folds `SUPABASE_DB_MAJOR_VERSION` into `c.Db.MajorVersion` before the // image-selection switch runs (`pkg/config/config.go:585-586,819-827`) — every later read of // `utils.Config.Db.MajorVersion` sees this same value. Not wrapped: `legacyCheckDbToml` // (called by both callers before this function) already validates this override. - const majorVersion = legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); + // A matched remote block's `db.major_version` was installed at viper's OVERRIDE tier + // (above `AutomaticEnv`), so it must win over a conflicting `SUPABASE_DB_MAJOR_VERSION`. + const majorVersion = remoteWins("db.major_version") + ? config.db.major_version + : legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); // `experimental.orioledb_version` -> `Config.Db.Image` rewrite (`pkg/config/config.go: // 1041-1046`), plus its four sibling S3 fields Go reads into the Postgres container's `S3_*` // env alongside it (`apps/cli-go/internal/db/start/start.go:70-77`). Both `legacyEnvOverride` // calls never throw (return the override or the configured value verbatim), so no wrap needed. - const orioledbVersion = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", - config.experimental.orioledb_version, - projectEnvValues, - ); - const s3Host = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_HOST", - config.experimental.s3_host, - projectEnvValues, - ); - const s3Region = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_REGION", - config.experimental.s3_region, - projectEnvValues, - ); - const s3AccessKey = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", - config.experimental.s3_access_key, - projectEnvValues, - ); - const s3SecretKey = legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", - config.experimental.s3_secret_key, - projectEnvValues, - ); + // Same remote-over-env precedence as `majorVersion` above applies to each of these. + const orioledbVersion = remoteWins("experimental.orioledb_version") + ? config.experimental.orioledb_version + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", + config.experimental.orioledb_version, + projectEnvValues, + ); + const s3Host = remoteWins("experimental.s3_host") + ? config.experimental.s3_host + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_HOST", + config.experimental.s3_host, + projectEnvValues, + ); + const s3Region = remoteWins("experimental.s3_region") + ? config.experimental.s3_region + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_REGION", + config.experimental.s3_region, + projectEnvValues, + ); + const s3AccessKey = remoteWins("experimental.s3_access_key") + ? config.experimental.s3_access_key + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", + config.experimental.s3_access_key, + projectEnvValues, + ); + const s3SecretKey = remoteWins("experimental.s3_secret_key") + ? config.experimental.s3_secret_key + : legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", + config.experimental.s3_secret_key, + projectEnvValues, + ); // Go's one-shot fresh-DB setup jobs (`initSchema15`) read `utils.Config. // {Realtime,Storage,Auth}.Enabled` — the EFFECTIVE, env-overridden value — and run @@ -153,34 +184,40 @@ export const legacyResolveDbBootstrapConfig = ( const realtimeEnabledForSetup = yield* wrapConfigOverride( "realtime.enabled", () => - legacyEnvOverrideBool( - "SUPABASE_REALTIME_ENABLED", - config.realtime.enabled, - "realtime.enabled", - projectEnvValues, - ), + remoteWins("realtime.enabled") + ? config.realtime.enabled + : legacyEnvOverrideBool( + "SUPABASE_REALTIME_ENABLED", + config.realtime.enabled, + "realtime.enabled", + projectEnvValues, + ), mapConfigError, ); const storageEnabledForSetup = yield* wrapConfigOverride( "storage.enabled", () => - legacyEnvOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ), + remoteWins("storage.enabled") + ? config.storage.enabled + : legacyEnvOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + "storage.enabled", + projectEnvValues, + ), mapConfigError, ); const authEnabledForSetup = yield* wrapConfigOverride( "auth.enabled", () => - legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ), + remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ), mapConfigError, ); @@ -190,16 +227,35 @@ export const legacyResolveDbBootstrapConfig = ( // `internal/start/start.go:922,928`, `internal/db/start/start.go:283,290`). const realtimeIpVersion = yield* wrapConfigOverride( "realtime.ip_version", - () => legacyEnvOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues), + () => { + // `legacyEnvOverrideRealtimeIpVersion` itself reads `process.env` unconditionally + // (`legacyEnvOverride`'s own fallback, regardless of `projectEnvValues`), so it can't + // simply be called with a neutered `projectEnvValues` here — that would still let a + // raw shell `SUPABASE_REALTIME_IP_VERSION` beat the remote block's viper OVERRIDE-tier + // value. Skip the override call entirely on this branch instead, re-validating into + // the same narrow type (the value is already guaranteed one of these two literals by + // `@supabase/config`'s own schema decode — `stringEnum(["IPv4","IPv6"])` — this only + // narrows the TS type to match {@link LegacyDbBootstrapConfig.realtimeIpVersion}). + if (remoteWins("realtime.ip_version")) { + const value = config.realtime.ip_version; + if (value !== "IPv4" && value !== "IPv6") { + throw new LegacyInvalidRealtimeIpVersionEnvOverrideError("realtime.ip_version", value); + } + return value; + } + return legacyEnvOverrideRealtimeIpVersion(config.realtime.ip_version, projectEnvValues); + }, mapConfigError, ); const realtimeMaxHeaderLength = yield* wrapConfigOverride( "realtime.max_header_length", () => - legacyEnvOverrideRealtimeMaxHeaderLength( - config.realtime.max_header_length, - projectEnvValues, - ), + remoteWins("realtime.max_header_length") + ? config.realtime.max_header_length + : legacyEnvOverrideRealtimeMaxHeaderLength( + config.realtime.max_header_length, + projectEnvValues, + ), mapConfigError, ); @@ -212,12 +268,13 @@ export const legacyResolveDbBootstrapConfig = ( // `sizeInBytes.UnmarshalText`, `pkg/config/config.go:39-49`, decodes it unconditionally during // `Config.Load`, before either caller touches Docker) rather than left to surface only when a // container env builder happens to re-parse it. - const storageFileSizeLimit = - legacyEnvOverride( - "SUPABASE_STORAGE_FILE_SIZE_LIMIT", - config.storage.file_size_limit, - projectEnvValues, - ) ?? config.storage.file_size_limit; + const storageFileSizeLimit = remoteWins("storage.file_size_limit") + ? config.storage.file_size_limit + : (legacyEnvOverride( + "SUPABASE_STORAGE_FILE_SIZE_LIMIT", + config.storage.file_size_limit, + projectEnvValues, + ) ?? config.storage.file_size_limit); yield* wrapConfigOverride( "storage.file_size_limit", () => ramInBytes(storageFileSizeLimit), @@ -248,11 +305,9 @@ export const legacyResolveDbBootstrapConfig = ( // Overridden by SUPABASE_DB_HEALTH_TIMEOUT — Go's Config.Load binds this generically before // StartDatabase's health wait reads it (pkg/config/config.go:580-586, internal/db/start/ // start.go:180). - const dbHealthTimeout = legacyEnvOverride( - "SUPABASE_DB_HEALTH_TIMEOUT", - config.db.health_timeout, - projectEnvValues, - ); + const dbHealthTimeout = remoteWins("db.health_timeout") + ? config.db.health_timeout + : legacyEnvOverride("SUPABASE_DB_HEALTH_TIMEOUT", config.db.health_timeout, projectEnvValues); const dbHealthTimeoutSeconds = yield* Effect.try({ try: () => legacyResolveHealthTimeoutSeconds(dbHealthTimeout ?? config.db.health_timeout), catch: (cause) => diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index 2a9d8657b6..1849ef3483 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -30,7 +30,11 @@ import { legacyBindMountSpecSource, legacyIsBindMountSource, } from "../legacy-docker-bind-classify.ts"; -import { LEGACY_CLI_PROJECT_LABEL, LEGACY_CLI_WORKDIR_LABEL } from "../legacy-docker-ids.ts"; +import { + LEGACY_CLI_PROJECT_LABEL, + LEGACY_CLI_SECRET_DIR_LABEL, + LEGACY_CLI_WORKDIR_LABEL, +} from "../legacy-docker-ids.ts"; import { isUserDefinedDockerNetwork } from "../../../shared/functions/deploy.ts"; import { legacyBuildStartContainerCreateArgs, @@ -133,6 +137,23 @@ export interface LegacyContainerOpts { * Go applies it identically to every container this orchestrator creates. */ readonly extraHosts: ReadonlyArray; + /** + * Fallback identifier for the {@link LEGACY_CLI_SECRET_DIR_LABEL} orphan-recovery label, + * used ONLY when `spec.containerName` is empty (Docker auto-generates the real name — see + * that field's own doc comment). The shadow database + * (`db-bootstrap/shadow-database.ts`'s `legacyCreateShadowDatabase`) is the only real + * caller, generating a randomized `shadow-` id per call so a later `stop`'s + * project-label-filtered sweep can still recognize an orphaned shadow container even + * though Docker's own auto-generated name bears no relation to it (review: + * PRRT_kwDOErm0O86W8ZYt). Every real service container has a non-empty `containerName` + * and never sets this. + * + * Purely a labeling concern, unrelated to how {@link LegacyStartContainerSpec.secretFiles} + * are actually delivered: those always travel via `docker cp` against the container's own + * id (see that field's own doc comment, `docker-create-args.ts`), which needs no naming + * fallback at all — `docker cp` addresses a container by id, never by name. + */ + readonly secretDirId?: string; } /** @@ -783,21 +804,39 @@ export function legacyCreateContainer( opts.isBitbucketPipeline, ); - const createArgs = legacyBuildStartContainerCreateArgs(finalSpec); + // `finalSpec.containerName` is empty only for a shadow database (Docker auto-generates + // the real name) — stamp `opts.secretDirId`, when supplied, as its own label so a later + // orphan sweep can still recognize this container even though Docker's own auto-generated + // name bears no relation to it (see `LegacyContainerOpts.secretDirId`'s own doc comment). A + // named container never gets this label: its secret directory IS its own name, which + // `docker ps` already reports back. Purely a labeling concern — it has no bearing on how + // `secretFiles` are delivered below, which always travels via `docker cp` against the + // container's own id, regardless of `containerName`/`secretDirId`. + const secretDirLabeledSpec: LegacyStartContainerSpec = + finalSpec.containerName.length === 0 && + opts.secretDirId !== undefined && + opts.secretDirId.length > 0 + ? { + ...finalSpec, + labels: { ...finalSpec.labels, [LEGACY_CLI_SECRET_DIR_LABEL]: opts.secretDirId }, + } + : finalSpec; + + const createArgs = legacyBuildStartContainerCreateArgs(secretDirLabeledSpec); // `legacyIsDockerClientEnvKey` keys (e.g. Vector's container-facing `DOCKER_HOST`) are // already emitted inline as `-e KEY=value` by `legacyBuildStartContainerCreateArgs` above — // see `legacyDockerCreateContainer`'s doc comment for why they must not also reach the // spawned `docker create` process's own environment. const createProcessEnv = Object.fromEntries( - Object.entries(finalSpec.env).filter(([key]) => !legacyIsDockerClientEnvKey(key)), + Object.entries(secretDirLabeledSpec.env).filter(([key]) => !legacyIsDockerClientEnvKey(key)), ); const containerId = yield* legacyDockerCreateContainer(spawner, createArgs, createProcessEnv); yield* legacyCopyStartSecretFilesIntoContainer( spawner, containerId, - finalSpec.secretFiles ?? [], + secretDirLabeledSpec.secretFiles ?? [], ); - yield* legacyDockerStartContainer(spawner, containerId, finalSpec); + yield* legacyDockerStartContainer(spawner, containerId, secretDirLabeledSpec); return containerId; }); } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts index 9a510dc374..d79d26b51d 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts @@ -947,3 +947,124 @@ describe("legacyRemoveVolume", () => { ); }); }); + +describe("legacyCreateContainer with an empty containerName (the shadow database)", () => { + it.live( + "omits --name from the create argv and still delivers secretFiles via `docker cp` against the container's own id, exactly like a named container", + () => { + let cpArgs: ReadonlyArray | undefined; + const mock = mockSpawner((args) => { + if (args[0] === "create") { + expect(args).not.toContain("--name"); + return { exitCode: 0, stdout: "shadow-container-id\n" }; + } + if (args[0] === "cp") { + cpArgs = args; + } + return { exitCode: 0 }; + }); + + const spec: LegacyStartContainerSpec = { + ...baseSpec, + containerName: "", + binds: [], + networkAliases: undefined, + autoRemove: true, + secretFiles: [ + { containerPath: "/etc/postgresql-custom/pgsodium_root.key", content: "root-key" }, + ], + }; + + return legacyCreateContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + secretDirId: "shadow", + }).pipe( + Effect.map((containerId) => { + expect(containerId).toBe("shadow-container-id"); + // `docker cp` addresses the container by the id `docker create` returned, never by + // name — the unnamed shadow container is delivered its secret the same way a named + // one is. + expect(cpArgs?.[0]).toBe("cp"); + expect(cpArgs?.[2]).toBe("shadow-container-id:/etc/postgresql-custom/pgsodium_root.key"); + }), + ); + }, + ); + + it.live( + "does not stamp a com.supabase.cli.secret-dir label on a named container, even when an (irrelevant) secretDirId is supplied", + () => { + const mock = alwaysSucceed("real-name-container-id\n"); + const spec: LegacyStartContainerSpec = { + ...baseSpec, + secretFiles: [{ containerPath: "/etc/kong/kong.yml", content: "secret" }], + }; + return legacyCreateContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + secretDirId: "should-be-ignored", + }).pipe( + Effect.map(() => { + // A NAMED container never gets `LEGACY_CLI_SECRET_DIR_LABEL` — its secret + // directory IS its own name, which `docker ps` already reports back; stamping + // this label unconditionally would be redundant and could be mistaken for the + // "this container's secretDirId isn't its name" signal that label exists to give + // orphan cleanup on the unnamed-container path (review: PRRT_kwDOErm0O86W8ZYt). + const create = mock.spawned.find((args) => args[0] === "create"); + expect(create?.some((arg) => arg.startsWith("com.supabase.cli.secret-dir="))).toBe(false); + }), + ); + }, + ); + + it.live( + "stamps an unnamed container with a com.supabase.cli.secret-dir label matching opts.secretDirId", + () => { + // A later `stop`'s project-label-filtered reaping needs this label to recognize an + // orphaned shadow container at all, since Docker's own auto-generated name bears no + // relation to the randomized `secretDirId` the shadow's own caller generated (review: + // PRRT_kwDOErm0O86W8ZYt, `LEGACY_CLI_SECRET_DIR_LABEL`'s own doc comment). + const mock = alwaysSucceed("shadow-container-id\n"); + const spec: LegacyStartContainerSpec = { ...baseSpec, containerName: "", binds: [] }; + return legacyCreateContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + secretDirId: "shadow-11111111-1111-1111-1111-111111111111", + }).pipe( + Effect.map(() => { + const create = mock.spawned.find((args) => args[0] === "create"); + expect(create).toContain( + "com.supabase.cli.secret-dir=shadow-11111111-1111-1111-1111-111111111111", + ); + }), + ); + }, + ); + + it.live( + "creates and starts an unnamed container with no secret-dir label (and no failure) when containerName is empty and no secretDirId is supplied", + () => { + const mock = alwaysSucceed("shadow-container-id\n"); + const spec: LegacyStartContainerSpec = { ...baseSpec, containerName: "", binds: [] }; + return legacyCreateContainer(mock.spawner, spec, { + projectId: "proj", + isBitbucketPipeline: false, + workdir, + extraHosts: [], + }).pipe( + Effect.map((containerId) => { + expect(containerId).toBe("shadow-container-id"); + const create = mock.spawned.find((args) => args[0] === "create"); + expect(create?.some((arg) => arg.startsWith("com.supabase.cli.secret-dir="))).toBe(false); + }), + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index 33d2ccd90a..d9e3157aa1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -86,7 +86,10 @@ * Go env override is threaded explicitly via `projectEnvValues` — so this ONE * call is scoped with `legacyApplyProjectEnv` (the same opt-in helper `db * push`/`db pull`/`db dump`/`bootstrap` already use around their own pg-delta/ - * image work) for just its own duration, then reverted. + * image work) for just its own duration, then reverted. `legacySetupDatabase` + * (CLI-1956's extraction of steps 1-4 above, reused by shadow-database + * provisioning) never reaches this step at all — only this function's own + * trailing `MigrateAndSeed` + pgcache tail does. * * Go's `initCurrentBranch` (`start.go:233-241`, writes `supabase/.branches/ * _current_branch` = `"main"` if absent) is NOT part of this pipeline, even though @@ -124,7 +127,7 @@ import { legacyResolveSeedSqlPath, } from "../legacy-db-config.toml-read.ts"; import { legacyParseBoolEnv } from "../legacy-diff-engine.ts"; -import { LEGACY_CLI_PROJECT_LABEL, legacyServiceContainerName } from "../legacy-docker-ids.ts"; +import { LEGACY_CLI_PROJECT_LABEL, localDbContainerId } from "../legacy-docker-ids.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; import { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; @@ -134,7 +137,11 @@ import type { LegacyPgDeltaContext } from "../legacy-pgdelta.ts"; import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import type { LegacyMigrationSeedError, LegacySeedConfig } from "../legacy-seed.ts"; import { ramInBytes } from "../legacy-size-units.ts"; -import { LegacyMigrationVaultError, legacyUpsertVaultSecrets } from "../legacy-vault.ts"; +import { + LegacyMigrationVaultError, + type LegacyVaultSecret, + legacyUpsertVaultSecrets, +} from "../legacy-vault.ts"; import { legacyEnsureImagesCached, type LegacyImagePrepullError } from "./image-prepull.ts"; import { legacyResolvePinnedImage } from "./pinned-image.ts"; import { LEGACY_COMPOSE_PROJECT_LABEL } from "./container-lifecycle.ts"; @@ -185,7 +192,7 @@ export type LegacyStartSetupLocalDatabaseError = | LegacyImagePrepullError; /** Already-resolved Docker images for the three PG15+ one-shot migrate jobs (`initSchema15`'s `initJobs`). */ -interface LegacyStartDbSetupImages { +export interface LegacyStartDbSetupImages { /** `utils.Config.Realtime.Image`, resolved by the caller (not part of the decoded `ProjectConfig` schema — `toml:"-"`). */ readonly realtime: string; /** `utils.Config.Storage.Image`, ditto. */ @@ -196,16 +203,17 @@ interface LegacyStartDbSetupImages { /** * Computes the three PG15+ one-shot setup jobs' PINNED image names (`initSchema15`'s - * `initRealtimeJob`/`initStorageJob`/`initAuthJob`) for {@link legacyRunFreshDbSetup} — the - * ONE place both real Go callers (`db start`'s fresh-volume branch and `db reset`'s PG15 - * recreate) reach this from. Mirrors Go's `initSchema15`, which uses the SAME - * already-pin-rewritten `utils.Config.{Realtime,Storage,Auth}.Image` fields the - * long-running containers would use, regardless of `--exclude` — resolved via - * `legacyResolvePinnedImage`, not the raw Dockerfile default, so a linked project's - * version pins apply here too. Deliberately does NOT resolve these against the registry - * (`legacyEnsureImagesCached`) as a batch: Go resolves (and pulls) each one-shot job's - * own image individually, sequentially, right before THAT job runs (`DockerRunJob` -> - * `DockerStart` -> `DockerResolveImageIfNotCached`, `start.go:334-355`, + * `initRealtimeJob`/`initStorageJob`/`initAuthJob`) for {@link legacyResolveDbSetupPrelude}, + * the ONE place every real caller (`db start`'s fresh-volume branch, `db reset`'s PG15 + * recreate, and the shadow-database variant's `legacySetupShadowDatabase`/ + * `legacyMigrateShadowDatabase`) reaches this resolution from — see that function's own doc + * comment. Mirrors Go's `initSchema15`, which uses the SAME already-pin-rewritten + * `utils.Config.{Realtime,Storage,Auth}.Image` fields the long-running containers would use, + * regardless of `--exclude` — resolved via `legacyResolvePinnedImage`, not the raw Dockerfile + * default, so a linked project's version pins apply here too. Deliberately does NOT resolve + * these against the registry (`legacyEnsureImagesCached`) as a batch: Go resolves (and pulls) + * each one-shot job's own image individually, sequentially, right before THAT job runs + * (`DockerRunJob` -> `DockerStart` -> `DockerResolveImageIfNotCached`, `start.go:334-355`, * `docker.go:363-365`) — {@link legacyRunStartMigrateJob} does that lazily itself, right * before running each job (see its own doc comment): a batch resolve here would let one * unreachable image fail the WHOLE setup before an earlier job Go would already have run @@ -221,8 +229,73 @@ function legacyResolveDbSetupImages( }; } -/** Input to {@link legacyStartSetupLocalDatabase}. */ -export interface LegacyStartSetupLocalDatabaseInput { +/** + * Prints the banner + resolves JWKS (lazily, only when `majorVersion >= 15` AND + * `realtimeEnabledForSetup`) + the PG15+ one-shot job images' PINNED names (via + * {@link legacyResolveDbSetupImages}) — the exact prelude BOTH {@link legacyRunFreshDbSetup} + * (the real local `db` container) and `shadow-database.ts`'s + * `legacySetupShadowDatabase`/`legacyMigrateShadowDatabase` need before calling + * {@link legacySetupDatabase}. Hoisted here (CLI-1956 review follow-up) so the shadow path + * shares this exact resolution instead of keeping its own copy, which had silently drifted (a + * dead, never-forwarded `jwtExpiry` field on the shadow's own setup-input shape). Structurally + * typed against just the fields this needs (not the full {@link LegacyFreshDbSetupInput}) so + * both that type and `shadow-database.ts`'s `LegacyShadowDbSetupInput` — which is itself + * derived from it — satisfy this signature without an explicit cast. + * + * The banner print lives HERE, not in {@link legacySetupDatabase}'s own `initSchema` step, + * even though Go's `initSchema` (`start.go:243-254`) prints it immediately before branching on + * `MajorVersion` and, for PG15+, calling `initSchema15` -> `Config.Auth.ResolveJWKS` + * (`start.go:334-343`) — i.e. in Go, the print and the JWKS fetch are two steps of the SAME + * `initSchema` call, print first. This module's `jwks` field is a plain, already-resolved + * `string` on {@link LegacySetupDatabaseInput} (not a lazy effect `legacySetupDatabase` itself + * runs), so it MUST be resolved by the caller before `legacySetupDatabase` is ever invoked — + * printing the banner here, immediately before that resolution, is the only way to reproduce + * Go's exact observable order (banner, THEN a possible JWKS failure) without restructuring + * `legacySetupDatabase`'s input to carry a lazy JWKS effect instead. Previously the print lived + * solely in `legacyStartInitSchema` below, AFTER this whole prelude — so a JWKS discovery + * failure (realtime enabled, PG15+, third-party JWKS unreachable) meant `db diff --linked`/ + * `db pull`'s native shadow-provisioning path failed BEFORE ever printing "Initialising + * schema...", where Go always prints it first (review: PRRT_kwDOErm0O86W6R-O). + * + * The `majorVersion >= 15` gate matters, not just an optimization: Go's `initSchema` + * (`apps/cli-go/internal/db/start/start.go:243-253`) returns via `InitSchema14` for + * `MajorVersion <= 14` WITHOUT ever calling `initSchema15`, so `Config.Auth.ResolveJWKS` + * (`start.go:338`, only reached from `initSchema15`) never runs at all on PG13/14 — even + * with realtime enabled. `ResolveJWKS` can perform live discovery/JWKS HTTP requests for + * configured `auth.third_party` providers, so resolving it unconditionally on PG14 is not + * just wasted work: it can fail (or hang) when Go's own shadow/setup never would. + */ +export const legacyResolveDbSetupPrelude = (setup: { + readonly majorVersion: number; + readonly realtimeEnabledForSetup: boolean; + readonly serviceVersionOverrides: LocalServiceVersionOverrides; + readonly jwks: Effect.Effect; +}): Effect.Effect< + { readonly jwks: string; readonly images: LegacyStartDbSetupImages }, + E, + Output +> => + Effect.gen(function* () { + const output = yield* Output; + yield* output.raw("Initialising schema...\n", "stderr"); + const jwks = setup.majorVersion >= 15 && setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; + const images = legacyResolveDbSetupImages(setup.serviceVersionOverrides); + return { jwks, images }; + }); + +/** + * Input to {@link legacySetupDatabase} — Go's EXPORTED `SetupDatabase(ctx, conn, host, w, + * fsys)` (`start.go:383-399`): `initSchema -> ApplyApiPrivileges -> vault upsert -> + * SeedGlobals(roles.sql)`, deliberately WITHOUT `apply.MigrateAndSeed` (that extra step is + * what makes {@link LegacyStartSetupLocalDatabaseInput}/{@link legacyStartSetupLocalDatabase} + * bigger — see that interface's own doc comment). Extracted as its own exported shape + * (CLI-1956) so shadow-database provisioning (`shadow-database.ts`) can reach the exact same + * platform-baseline pipeline the real local `db` container's fresh-volume setup does, without + * also replaying migrations a second time or reaching `legacyMigrateAndSeed`'s + * declarative-schema-files branch, neither of which Go's own shadow provisioning + * (`setupShadowConn`) ever does either. + */ +export interface LegacySetupDatabaseInput { /** * An already-open session to the local Postgres database, dialed the same way * Go's `ConnectLocalPostgres(ctx, pgconn.Config{})` does (`internal/utils/ @@ -231,7 +304,7 @@ export interface LegacyStartSetupLocalDatabaseInput { * config.layer.ts`'s own `--local` branch already dials (`legacy-db-config. * layer.ts:518-529`). This is deliberately NOT the internal Docker-network `db` * container address the PG15+ one-shot jobs below connect through (see - * `networkId`/`projectId`) — the two addressing schemes are independent, exactly + * `networkId`/`dbHost`) — the two addressing schemes are independent, exactly * like Go's `conn` (host-facing) vs. `host` parameter (`utils.DbId`) in * `SetupDatabase(ctx, conn, utils.DbId, w, fsys)`. */ @@ -244,15 +317,30 @@ export interface LegacyStartSetupLocalDatabaseInput { readonly config: ProjectConfig; /** `db.major_version` (13-17) — Go's `utils.Config.Db.MajorVersion`, resolved by the caller once, ahead of the `db` container's own image tag selection. */ readonly majorVersion: number; - /** Go's `Config.ProjectId`, already sanitized (`legacySanitizeProjectId`) — derives the `db` container's internal Docker name for the PG15+ one-shot jobs (`legacyServiceContainerName("db", projectId)`, Go's `utils.DbId`). */ - readonly projectId: string; /** - * `--experimental`/`SUPABASE_EXPERIMENTAL`, resolved by the caller (Go's - * `viper.GetBool("EXPERIMENTAL")`) — threaded straight into - * {@link legacyMigrateAndSeed}'s own `experimental` gate (`internal/migration/apply/ - * apply.go:19`); this module has no other use for it. + * The internal Docker-network address the PG15+ one-shot jobs connect through — Go's + * `host` parameter to `SetupDatabase(ctx, conn, host, w, fsys)` (`start.go:383`). The real + * local `db` container's own caller (`legacyRunFreshDbSetup`) passes + * `legacyServiceContainerName("db", projectId)` (Go's `utils.DbId`, threaded straight + * through unchanged from before CLI-1956); the shadow-database variant + * (`shadow-database.ts`) passes the shadow container's own 12-char short id instead (Go's + * `container[:12]`, `apps/cli-go/internal/db/diff/diff.go:172` / `internal/migration/ + * squash/squash.go:96`) — empirically verified to resolve via Docker's embedded DNS even + * though the shadow container has no name/alias at all (see `shadow-database.ts`'s + * header). This field was hardcoded inside this module prior to CLI-1956; it is now the + * caller's responsibility, the one genuine parameterization this port needed for shadow + * provisioning to reuse `SetupDatabase` at all. */ - readonly experimental: boolean; + readonly dbHost: string; + /** + * Go's `Config.ProjectId` — labels the PG15+ one-shot job containers + * (`com.supabase.cli.project`/`com.docker.compose.project`, see {@link + * legacyRunStartMigrateJob}), matching Go's `DockerStart`, which sets both + * unconditionally for every container it starts (`docker.go:371-376`). Independent of + * {@link dbHost}: this labels the one-shot job containers THEMSELVES, not the (possibly + * different) container `dbHost` addresses. + */ + readonly projectId: string; /** The `start` run's Docker network id (Go's `utils.NetId` or the `--network-id` override) — every PG15+ one-shot job joins it, matching `DockerStart`'s own default (`docker.go:379-383`). */ readonly networkId: string; /** `LegacyLocalConfigValues.dbUrl` — reused (not recomputed) to derive the internal DB password via `legacyStartInternalDbPassword`, matching every other `start/services/*.service.ts` builder. */ @@ -308,6 +396,25 @@ export interface LegacyStartSetupLocalDatabaseInput { * `utils.GetDebugLogger()` as the job's stderr writer (`start.go:349-353`). */ readonly debug: boolean; + /** `toml.baseline.apiAutoExposeNewTables` — Go's `api.auto_expose_new_tables` tri-state, threaded straight into {@link legacyApplyApiPrivileges}. */ + readonly apiAutoExposeNewTables: Option.Option; + /** `toml.vault` — Go's `utils.Config.Db.Vault`, threaded straight into {@link legacyUpsertVaultSecrets}. */ + readonly vault: ReadonlyArray; +} + +/** Input to {@link legacyStartSetupLocalDatabase}. */ +export interface LegacyStartSetupLocalDatabaseInput extends Omit< + LegacySetupDatabaseInput, + "apiAutoExposeNewTables" | "vault" +> { + /** + * `--experimental`/`SUPABASE_EXPERIMENTAL`, resolved by the caller (Go's + * `viper.GetBool("EXPERIMENTAL")`) — threaded straight into + * {@link legacyMigrateAndSeed}'s own `experimental` gate (`internal/migration/apply/ + * apply.go:19`); `legacySetupDatabase`/Go's own `SetupDatabase` have no use for it — + * only this function's own trailing `MigrateAndSeed` call does. + */ + readonly experimental: boolean; /** * The migration version to reapply (Go's `apply.MigrateAndSeed(ctx, version, ...)`). * `db start`'s own caller always passes `""` (Go's `SetupLocalDatabase(ctx, "", ...)`, @@ -596,9 +703,9 @@ function legacyStartAuthMigrateEnv(input: { */ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( spawner: Spawner, - input: LegacyStartSetupLocalDatabaseInput, + input: LegacySetupDatabaseInput, ) { - const dbHost = legacyServiceContainerName("db", input.projectId); + const dbHost = input.dbHost; const dbPassword = legacyStartInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { @@ -684,18 +791,19 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( }); /** - * Port of Go's `initSchema` (`start.go:243-254`): prints the banner line once, - * then branches on PG major version — unconditionally, for BOTH branches, exactly - * matching Go's `fmt.Fprintln(w, "Initialising schema...")` running before the - * `if utils.Config.Db.MajorVersion <= 14` check. + * Port of Go's `initSchema` (`start.go:243-254`) MINUS the banner print: branches on PG major + * version — unconditionally, for both branches. The banner itself + * (`fmt.Fprintln(w, "Initialising schema...")`, printed before the `if + * utils.Config.Db.MajorVersion <= 14` check) now prints from + * {@link legacyResolveDbSetupPrelude}, the caller-side step that runs immediately before this + * one — see that function's own doc comment for why the print had to move there instead of + * staying here. */ const legacyStartInitSchema = Effect.fnUntraced(function* ( spawner: Spawner, - input: LegacyStartSetupLocalDatabaseInput, + input: LegacySetupDatabaseInput, tmpDir: string, ) { - const output = yield* Output; - yield* output.raw("Initialising schema...\n", "stderr"); if (input.majorVersion <= 14) { yield* legacyStartInitSchemaPre15( input.session, @@ -791,49 +899,23 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( }); /** - * Runs the full `SetupLocalDatabase`-equivalent sequence — see this module's - * header for the exact Go call chain and line-range citations. Call once, right - * after the `db` container's healthcheck passes on a fresh volume (Go's - * `NoBackupVolume` gate); the caller decides that gating, this function performs - * no health/readiness checks of its own. + * Runs Go's EXPORTED `SetupDatabase(ctx, conn, host, w, fsys)` (`start.go:383-399`) — + * see {@link LegacySetupDatabaseInput}'s own doc comment for exactly what's in and out of + * scope. Extracted out of {@link legacyStartSetupLocalDatabase} (CLI-1956) so shadow-database + * provisioning can reuse this exact sequence without also reaching `apply.MigrateAndSeed`. */ -export const legacyStartSetupLocalDatabase = ( +export const legacySetupDatabase = ( spawner: Spawner, - input: LegacyStartSetupLocalDatabaseInput, + input: LegacySetupDatabaseInput, ): Effect.Effect< void, - LegacyStartSetupLocalDatabaseError, - | Output - | LegacyDockerRun - | RuntimeInfo - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe - // `legacyTryCacheMigrationsCatalog`'s own pg-delta export call resolves - // `FileSystem.FileSystem`/`Path.Path` from the effect context itself (not from - // the `fs`/`path` values this function already threads through as plain data — - // see `legacy-pgdelta.ts`'s `legacyExportCatalogPgDelta`), so both must be - // ambient here too; every real caller already gets them from `BunServices.layer` - // at the CLI root runtime, same as `db push`'s own composition. - | FileSystem.FileSystem - | Path.Path + LegacyDbSetupError | LegacyMigrationVaultError | LegacyImagePrepullError, + Output | LegacyDockerRun | RuntimeInfo > => Effect.gen(function* () { const { session, fs, path, workdir } = input; - // `warnOnUnresolvedEnv: false` — both `start.handler.ts` and `db/start/ - // start.handler.ts` already ran an earlier, same-invocation `legacyCheckDbToml` - // purely for its Go-parity validation side effect (their own callers discard the - // result) before ever reaching this fresh-volume setup, so that earlier call - // already printed Go's single `assertEnvLoaded` OrioleDB S3 WARN, if any. Without - // this, this module's own accepted duplicate config-load pass (see this module's - // header) would print the SAME warning a second time — a real, observable stderr - // divergence from Go's exactly-once `flags.LoadConfig`, unlike the harmless - // resolved-value duplication the header describes. - const toml = yield* legacyCheckDbToml(fs, path, workdir, undefined, { - warnOnUnresolvedEnv: false, - }); - - // SetupDatabase: initSchema -> ApplyApiPrivileges (start.go:383-389). + // initSchema -> ApplyApiPrivileges (start.go:383-389). yield* Effect.scoped( Effect.gen(function* () { const tmpDir = yield* fs @@ -847,18 +929,12 @@ export const legacyStartSetupLocalDatabase = ( ), ); yield* legacyStartInitSchema(spawner, input, tmpDir); - yield* legacyApplyApiPrivileges( - session, - fs, - path, - tmpDir, - toml.baseline.apiAutoExposeNewTables, - ); + yield* legacyApplyApiPrivileges(session, fs, path, tmpDir, input.apiAutoExposeNewTables); }), ); // "Create vault secrets first so roles.sql can reference them" (start.go:390). - yield* legacyUpsertVaultSecrets(session, toml.vault); + yield* legacyUpsertVaultSecrets(session, input.vault); // Custom-roles seed (start.go:394-398, pkg/migration/seed.go:84-97): Go's // `SeedGlobals` prints "Seeding globals from roles.sql..." BEFORE attempting @@ -891,6 +967,56 @@ export const legacyStartSetupLocalDatabase = ( (message) => new LegacyDbSetupError({ message }), ); } + }); + +/** + * Runs the full `SetupLocalDatabase`-equivalent sequence — see this module's + * header for the exact Go call chain and line-range citations. Call once, right + * after the `db` container's healthcheck passes on a fresh volume (Go's + * `NoBackupVolume` gate); the caller decides that gating, this function performs + * no health/readiness checks of its own. + */ +export const legacyStartSetupLocalDatabase = ( + spawner: Spawner, + input: LegacyStartSetupLocalDatabaseInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError, + | Output + | LegacyDockerRun + | RuntimeInfo + | LegacyEdgeRuntimeScript + | LegacyPgDeltaSslProbe + // `legacyTryCacheMigrationsCatalog`'s own pg-delta export call resolves + // `FileSystem.FileSystem`/`Path.Path` from the effect context itself (not from + // the `fs`/`path` values this function already threads through as plain data — + // see `legacy-pgdelta.ts`'s `legacyExportCatalogPgDelta`), so both must be + // ambient here too; every real caller already gets them from `BunServices.layer` + // at the CLI root runtime, same as `db push`'s own composition. + | FileSystem.FileSystem + | Path.Path +> => + Effect.gen(function* () { + const { session, fs, path, workdir } = input; + + // `warnOnUnresolvedEnv: false` — both `start.handler.ts` and `db/start/ + // start.handler.ts` already ran an earlier, same-invocation `legacyCheckDbToml` + // purely for its Go-parity validation side effect (their own callers discard the + // result) before ever reaching this fresh-volume setup, so that earlier call + // already printed Go's single `assertEnvLoaded` OrioleDB S3 WARN, if any. Without + // this, this module's own accepted duplicate config-load pass (see this module's + // header) would print the SAME warning a second time — a real, observable stderr + // divergence from Go's exactly-once `flags.LoadConfig`, unlike the harmless + // resolved-value duplication the header describes. + const toml = yield* legacyCheckDbToml(fs, path, workdir, undefined, { + warnOnUnresolvedEnv: false, + }); + + yield* legacySetupDatabase(spawner, { + ...input, + apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, + vault: toml.vault, + }); // apply.MigrateAndSeed(ctx, version, conn, fsys) — `db start`'s own caller always // passes `version: ""` (every pending migration, matching `SetupLocalDatabase`'s @@ -913,6 +1039,8 @@ export const legacyStartSetupLocalDatabase = ( schemaPaths: toml.schemaPaths, }); + const output = yield* Output; + // pgcache.TryCacheMigrationsCatalog(ctx, pgconn.Config{Host: Config.Hostname, // Port: Config.Db.Port, User: "postgres", Password: Config.Db.Password, Database: // "postgres"}, "local", version, fsys, ...) (start.go:371-379): best-effort, run @@ -938,6 +1066,7 @@ export const legacyStartSetupLocalDatabase = ( cwd: workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }; const hostDbUrl = new URL(input.dbUrl); // Scope the `PGDELTA_NPM_REGISTRY`-from-project-`.env` apply to just this call: @@ -1001,7 +1130,7 @@ export interface LegacyFreshDbSetupInput { readonly experimental: boolean; readonly dbUrl: string; readonly jwtSecret: string; - /** Lazy — evaluated only when reached AND `realtimeEnabledForSetup`. See `start-database.ts`'s header for why this is caller-supplied rather than resolved here unconditionally. */ + /** Lazy — evaluated only when reached AND `majorVersion >= 15` AND `realtimeEnabledForSetup` (see {@link legacyResolveDbSetupPrelude}'s own doc comment for the Go citation). See `start-database.ts`'s header for why this is caller-supplied rather than resolved here unconditionally. */ readonly jwks: Effect.Effect; readonly apiUrl: string; readonly authExternalUrl: string | undefined; @@ -1027,11 +1156,9 @@ export interface LegacyFreshDbSetupInput { * healthcheck passes on a fresh database (`db start`'s fresh-volume branch and * `db reset`'s PG15 recreate, see {@link LegacyFreshDbSetupInput}'s own doc * comment): dial the host-facing session (Go's `ConnectLocalPostgres`), resolve - * JWKS lazily (only when `majorVersion >= 15` AND `realtimeEnabledForSetup` — Go's - * `initSchema`, `start.go:243-254`, only ever reaches `initSchema15`'s - * `ResolveJWKS` call on PG15+; the PG13/14 branch, `InitSchema14`, never touches - * JWKS at all), compute the three PG15+ one-shot job images' PINNED names via - * {@link legacyResolveDbSetupImages}, then run {@link legacyStartSetupLocalDatabase} + * JWKS + the three PG15+ one-shot job images' PINNED names via {@link + * legacyResolveDbSetupPrelude} (the same hoisted prelude the shadow-database variant + * uses), then run {@link legacyStartSetupLocalDatabase} * itself. `version`/`seedFlags` are the one genuine difference between the two * callers (`db start` always passes `""`/`{noSeed:false, sqlPaths:[]}`; `db * reset` passes its own resolved reset version/flags) — threaded straight @@ -1080,14 +1207,7 @@ export const legacyRunFreshDbSetup = ( { isLocal: true, dnsResolver: "native" }, ); - // Go's `initSchema` (`start.go:243-254`) branches to `initSchema15` — the ONLY place - // `ResolveJWKS` is ever called — solely on `majorVersion >= 15`; the PG13/14 branch - // (`InitSchema14`) never touches JWKS, so a PG13/14 database with realtime enabled - // must not pay for (or fail on) an external JWKS fetch it will never use. - const jwks = - setup.majorVersion >= 15 && setup.realtimeEnabledForSetup ? yield* setup.jwks : ""; - - const dbSetupImages = legacyResolveDbSetupImages(setup.serviceVersionOverrides); + const { jwks, images: dbSetupImages } = yield* legacyResolveDbSetupPrelude(setup); yield* legacyStartSetupLocalDatabase(spawner, { session, @@ -1097,6 +1217,10 @@ export const legacyRunFreshDbSetup = ( config: setup.config, experimental: setup.experimental, majorVersion: setup.majorVersion, + // Go's `utils.DbId` — the internal Docker-network address the PG15+ one-shot + // jobs connect through. Unchanged from before CLI-1956, just now an explicit + // parameter on `LegacySetupDatabaseInput` instead of computed inside it. + dbHost: localDbContainerId(input.projectId), projectId: input.projectId, networkId: input.networkId, dbUrl: setup.dbUrl, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index f7d6fdc8c0..c2edc53c35 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -20,6 +20,7 @@ import { import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDbSetupError, + legacyResolveDbSetupPrelude, legacyStartInitCurrentBranch, legacyStartSetupLocalDatabase, type LegacyStartSetupLocalDatabaseInput, @@ -183,6 +184,7 @@ function baseInput( config: defaultConfig, experimental: false, majorVersion: 17, + dbHost: "supabase_db_proj", projectId: "proj", networkId: "supabase_network_proj", dbUrl: "postgresql://postgres:postgrespassword@127.0.0.1:54322/postgres", @@ -286,21 +288,6 @@ describe("legacyStartSetupLocalDatabase", () => { }), ); }); - - it.effect('prints "Initialising schema..." once, for either branch', () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - return run(baseInput(workdir, session, { majorVersion: 17 }), out, docker).pipe( - Effect.map(() => { - const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); - expect(banner.length).toBe(1); - expect(banner[0]?.stream).toBe("stderr"); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }); }); describe("PG15+ one-shot job gating", () => { @@ -383,7 +370,7 @@ describe("legacyStartSetupLocalDatabase", () => { baseInput(workdir, session, { majorVersion: 15, config, - projectId: "myproj", + dbHost: "supabase_db_myproj", jwks: '{"keys":["stub"]}', }), out, @@ -787,6 +774,75 @@ describe("legacyStartSetupLocalDatabase", () => { }); }); +describe("legacyResolveDbSetupPrelude", () => { + const run = ( + setup: { + readonly majorVersion: number; + readonly realtimeEnabledForSetup: boolean; + readonly jwks: Effect.Effect; + }, + out: ReturnType, + ) => + legacyResolveDbSetupPrelude({ ...setup, serviceVersionOverrides: {} }).pipe( + Effect.provide(out.layer), + ); + + it.effect('prints "Initialising schema..." to stderr exactly once, for either PG branch', () => { + const out = mockOutput(); + return run( + { majorVersion: 17, realtimeEnabledForSetup: false, jwks: Effect.succeed("") }, + out, + ).pipe( + Effect.map(() => { + const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); + expect(banner.length).toBe(1); + expect(banner[0]?.stream).toBe("stderr"); + }), + ); + }); + + it.effect( + 'prints the banner BEFORE a JWKS resolution failure — matching Go\'s "initSchema" printing the banner before ever calling "initSchema15" -> "ResolveJWKS" (review: PRRT_kwDOErm0O86W6R-O)', + () => { + const out = mockOutput(); + return run( + { + majorVersion: 15, + realtimeEnabledForSetup: true, + jwks: Effect.fail(new Error("jwks discovery failed")), + }, + out, + ).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toBe("jwks discovery failed"); + const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); + expect(banner.length).toBe(1); + expect(banner[0]?.stream).toBe("stderr"); + }), + ); + }, + ); + + it.effect( + "does not resolve JWKS on PG <= 14 even with realtime enabled — Go's initSchema never reaches initSchema15 there", + () => { + const out = mockOutput(); + let jwksCalled = false; + const jwks = Effect.sync(() => { + jwksCalled = true; + return "unused"; + }); + return run({ majorVersion: 14, realtimeEnabledForSetup: true, jwks }, out).pipe( + Effect.map((resolved) => { + expect(jwksCalled).toBe(false); + expect(resolved.jwks).toBe(""); + }), + ); + }, + ); +}); + describe("legacyStartInitCurrentBranch", () => { it.effect('writes supabase/.branches/_current_branch = "main" when absent', () => { const workdir = makeWorkdir(); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts index a0219f9995..4088d8a3d9 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.ts @@ -124,7 +124,14 @@ interface LegacyStartSecretFileSpec { export interface LegacyStartContainerSpec { /** `container.Config.Image` (already resolved/pulled — resolution is out of scope here). */ readonly image: string; - /** The 4th `DockerStart` positional argument — `--name`. */ + /** + * The 4th `DockerStart` positional argument — `--name`. An empty string mirrors Go + * passing `""` (e.g. `CreateShadowDatabase`, `apps/cli-go/internal/db/diff/diff.go:150`) + * and lets Docker auto-generate one — {@link legacyBuildStartContainerCreateArgs} omits + * `--name` entirely in that case (docker rejects an explicit empty `--name` value, unlike + * the Engine API's empty `containerName` positional, which it happily treats as "generate + * one"). Every real service container still passes a non-empty name, unchanged. + */ readonly containerName: string; /** * `container.Config.Hostname`. Only Logflare sets this (`start.go:353`, @@ -163,7 +170,10 @@ export interface LegacyStartContainerSpec { * container at `containerPath`, removing the temp file immediately * afterward — never a host bind mount. Generic by design — any future * service's spec can set this, not just the three call sites that need it - * today. + * today, and it makes no difference whether `containerName` is set: `docker + * cp` addresses the container by the id `docker create` returns, not by + * name, so the shadow database's own unnamed container (`db-bootstrap/ + * shadow-database.ts`) is delivered its pgsodium root key the exact same way. * * `docker cp` streams the file's content over the same Docker CLI/Engine * API connection as `docker create`/`docker start`, so — unlike the @@ -251,6 +261,15 @@ export interface LegacyStartContainerSpec { * supported for completeness/future callers, per the task brief. */ readonly restartPolicy?: "unless-stopped" | "no" | "always" | "on-failure"; + /** + * `container.HostConfig.AutoRemove` — only the shadow-database container sets this + * (`CreateShadowDatabase`, `apps/cli-go/internal/db/diff/diff.go:144`), via `--rm`. + * `AutoRemove` only fires once the container's own main process exits on its own; it + * does NOT make an explicit remove redundant for a still-running container (verified + * empirically), so callers still remove the shadow explicitly once they are done with + * it — see `shadow-database.ts`'s `legacyRemoveShadowDatabase`. + */ + readonly autoRemove?: boolean; /** * `container.HostConfig.SecurityOpt`. Only Vector sets this * (`start.go:441`, `"label:disable"`, when mounting a non-root Docker @@ -429,8 +448,8 @@ export function legacyBuildStartContainerCreateArgs( ): ReadonlyArray { return [ "create", - "--name", - spec.containerName, + ...(spec.containerName.length === 0 ? [] : ["--name", spec.containerName]), + ...(spec.autoRemove === true ? ["--rm"] : []), ...(spec.hostname === undefined ? [] : ["--hostname", spec.hostname]), ...Object.entries(spec.env).flatMap(([key, value]) => legacyIsDockerClientEnvKey(key) ? ["-e", `${key}=${value}`] : ["-e", key], diff --git a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts index 2211a3059a..d1cbca69e2 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/docker-create-args.unit.test.ts @@ -115,6 +115,36 @@ describe("legacyBuildStartContainerCreateArgs", () => { ]); }); + test("omits --name entirely when containerName is empty (Docker auto-generates one, e.g. the shadow database)", () => { + const spec: LegacyStartContainerSpec = { + image: "supabase/postgres:17.4.1.030", + containerName: "", + env: {}, + binds: [], + networkId: "supabase_network_proj", + labels: {}, + }; + const args = legacyBuildStartContainerCreateArgs(spec); + expect(args).not.toContain("--name"); + expect(args).toEqual(["create", "--network", "supabase_network_proj", spec.image]); + }); + + test("emits --rm when autoRemove is true, omits it otherwise", () => { + const base: LegacyStartContainerSpec = { + image: "supabase/postgres:17.4.1.030", + containerName: "", + env: {}, + binds: [], + networkId: "supabase_network_proj", + labels: {}, + }; + expect(legacyBuildStartContainerCreateArgs(base)).not.toContain("--rm"); + expect(legacyBuildStartContainerCreateArgs({ ...base, autoRemove: true })).toContain("--rm"); + expect(legacyBuildStartContainerCreateArgs({ ...base, autoRemove: false })).not.toContain( + "--rm", + ); + }); + test("never serializes env values into argv (CWE-214: secrets must not leak to ps)", () => { const args = legacyBuildStartContainerCreateArgs(full); expect(args).toContain("DB_PASSWORD"); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts index 4195cd8774..b5390536a3 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts @@ -100,6 +100,26 @@ export const legacyBuildLocalDbContainerInputs = ( networkIdFlag: Option.Option, platform: string, debug: boolean, + // The resolved `--linked` ref, when the caller already has one (`db diff`/`db pull` — + // CLI-1956) — threaded straight through to `legacyLoadLocalProjectContext` so the shadow's + // OWN container-spec fields (image, `db.major_version`, JWT secret, root key, + // `db.settings`, service enabled-for-setup flags) reflect the matching `[remotes.]` + // override, the same way `legacyReadDbToml(..., ref)` already does for those commands' + // other config read. `db start`/`db reset` never pass this — see that function's own doc + // comment. + projectRef?: string, + // The `remoteOverrideKeys` the caller's OWN, separate `legacyReadDbToml(..., ref)` read + // already computed for the SAME matched `[remotes.]` block (`@supabase/config`'s + // `loadProjectConfig`, used by `legacyLoadLocalProjectContext` just above, merges the + // remote block's VALUES but tracks none of which keys it set) — threaded into + // `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` AND + // `legacyResolveLocalConfigValues` below so a remote-set field (e.g. `db.major_version`, + // `auth.jwt_secret`, `db.root_key`) isn't re-overridden by a conflicting `SUPABASE_*` env + // var (review: PRRT_kwDOErm0O86W2LL4, PRRT_kwDOErm0O86W2tRi). Go's `mergeRemoteConfig` + // installs remote leaves at viper's OVERRIDE tier, above `AutomaticEnv` + // (`apps/cli-go/pkg/config/config.go:635-640`). + // `db start`/`db reset` never pass a `projectRef` above, so they never need this either. + remoteOverrideKeys?: ReadonlySet, ): Effect.Effect< LegacyLocalDbContainerInputs, LegacyDbConfigLoadError, @@ -110,7 +130,7 @@ export const legacyBuildLocalDbContainerInputs = ( const path = yield* Path.Path; const mapError = (message: string) => new LegacyDbConfigLoadError({ message }); - const context = yield* legacyLoadLocalProjectContext(workdir, mapError); + const context = yield* legacyLoadLocalProjectContext(workdir, mapError, projectRef); const { config, projectEnvValues, loaded, hostname, projectId } = context; // Go's `viper.GetBool("EXPERIMENTAL")` (`internal/migration/apply/apply.go:19`), read deep // inside `legacyRunFreshDbSetup`'s fresh-volume setup pipeline — see this field's own doc @@ -125,6 +145,7 @@ export const legacyBuildLocalDbContainerInputs = ( workdir, projectEnvValues, loaded?.document, + remoteOverrideKeys, ), catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), }); @@ -132,7 +153,7 @@ export const legacyBuildLocalDbContainerInputs = ( const bootstrapConfig = yield* legacyResolveDbBootstrapConfig( fs, path, - { config, projectEnvValues, workdir }, + { config, projectEnvValues, workdir, remoteOverrideKeys }, mapError, ); @@ -165,7 +186,11 @@ export const legacyBuildLocalDbContainerInputs = ( ...config.db, port: values.dbPort, major_version: bootstrapConfig.majorVersion, - settings: legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), + settings: legacyResolveDbSettingsEnvOverrides( + config.db.settings, + projectEnvValues, + remoteOverrideKeys, + ), }, experimental: { ...config.experimental, @@ -220,11 +245,22 @@ export const legacyBuildLocalDbContainerInputs = ( // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — `legacyRunFreshDbSetup` only // evaluates this Effect when reached AND `realtimeEnabledForSetup`. jwks: Effect.tryPromise({ - try: () => legacyResolveLocalJwks(config, workdir, values.jwtSecret, projectEnvValues), + try: () => + legacyResolveLocalJwks( + config, + workdir, + values.jwtSecret, + projectEnvValues, + remoteOverrideKeys, + ), catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), }), apiUrl: values.apiUrl, - authExternalUrl: legacyResolveAuthExternalUrl(loaded?.document, projectEnvValues), + authExternalUrl: legacyResolveAuthExternalUrl( + loaded?.document, + projectEnvValues, + remoteOverrideKeys, + ), siteUrl: values.authSiteUrl, anonKey: values.anonKey, serviceRoleKey: values.serviceRoleKey, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts index d6ea252181..5078eb8d9f 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.ts @@ -254,20 +254,25 @@ function legacyPostgresExtraEnv( * {@link legacyBuildPostgresStartContainerSpec}), so it never appears in this * process's own `docker create` argv (CWE-214/522). * - * Otherwise byte-for-byte derived from Go's raw-string concatenation - * (including the trailing space after `/etc/postgresql` — Go's - * `NewContainerConfig(args ...string)` joins its variadic `args` there, - * always empty for `supabase start`, so the space survives as-is); built via - * explicit `"...\n" +` concatenation rather than a multi-line template - * literal so that trailing space stays a visible, lint/format-proof string - * character instead of invisible end-of-line whitespace. + * Otherwise byte-for-byte derived from Go's raw-string concatenation — + * `NewContainerConfig(args ...string)` splices `strings.Join(args, " ")` + * straight after the literal trailing space following `/etc/postgresql` + * (`start.go:95`): `supabase start`'s own Postgres container always calls it + * with zero args (`args` here defaults to `""`, so the trailing space + * survives on its own, unchanged from before), while the shadow-database + * variant (`CreateShadowDatabase`, `apps/cli-go/internal/db/diff/diff.go:140`) + * passes {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} — see + * {@link legacyBuildShadowPostgresContainerSpec}. Built via explicit + * `"...\n" +` concatenation rather than a multi-line template literal so that + * the trailing space (when `args` is empty) stays a visible, lint/format-proof + * string character instead of invisible end-of-line whitespace. */ -function legacyPostgresEntrypointScriptPg15(postgresConfig: string): string { +function legacyPostgresEntrypointScriptPg15(postgresConfig: string, args = ""): string { return ( "\n" + "cat <<'EOF' > /etc/postgresql.schema.sql && \\\n" + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + - "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `docker-entrypoint.sh postgres -D /etc/postgresql ${args}\n` + `${LEGACY_START_DB_SCHEMA_SQL}\n` + `${LEGACY_START_DB_WEBHOOK_SQL}\n` + `${LEGACY_START_DB_SUPABASE_SQL}\n` + @@ -284,14 +289,15 @@ function legacyPostgresEntrypointScriptPg15(postgresConfig: string): string { * only), appends `postgresConfig` to `postgresql.conf`, then execs * `docker-entrypoint.sh`. See {@link legacyPostgresEntrypointScriptPg15}'s doc * comment for why this is explicit concatenation rather than a template - * literal. + * literal, and for the `args` parameter (same trailing-space splice, same + * default). */ -function legacyPostgresEntrypointScriptPg14(postgresConfig: string): string { +function legacyPostgresEntrypointScriptPg14(postgresConfig: string, args = ""): string { return ( "\n" + "cat <<'EOF' > /docker-entrypoint-initdb.d/supabase_schema.sql && \\\n" + "cat <<'EOF' >> /etc/postgresql/postgresql.conf && \\\n" + - "docker-entrypoint.sh postgres -D /etc/postgresql \n" + + `docker-entrypoint.sh postgres -D /etc/postgresql ${args}\n` + `${LEGACY_START_DB_SUPABASE_SQL}\n` + "EOF\n" + `${postgresConfig}\n` + @@ -401,3 +407,124 @@ export function legacyBuildPostgresStartContainerSpec( labels: {}, }; } + +/** + * Go's `NewContainerConfig("-c", "max_worker_processes=0")` (`CreateShadowDatabase`, + * `apps/cli-go/internal/db/diff/diff.go:140`) — disables background workers in the + * shadow database. Not a docker flag: it is spliced into the entrypoint script's own + * `docker-entrypoint.sh postgres -D /etc/postgresql ` line, exactly like every + * other `args` value {@link legacyPostgresEntrypointScriptPg15}/`Pg14` accept. + */ +export const LEGACY_SHADOW_ENTRYPOINT_ARGS = "-c max_worker_processes=0"; + +/** + * Input to {@link legacyBuildShadowPostgresContainerSpec} — the subset of + * {@link LegacyPostgresStartServiceInput} the shadow variant actually needs (no + * `projectId`/`fromBackup`: the shadow container has no name and never restores from a + * backup) plus the shadow's own host port. + */ +export interface LegacyShadowPostgresContainerSpecInput { + readonly db: Pick; + readonly experimental: ProjectConfig["experimental"]; + readonly jwtSecret: string; + readonly jwtExpiry: number; + readonly networkId: string; + readonly image: string; + readonly configImage: string; + readonly rootKey?: string; + /** `utils.Config.Db.ShadowPort` — the shadow's own host port, published to `5432/tcp` in-container. */ + readonly shadowPort: number; + /** + * `[db] password` (already resolved from `config.toml`, `DEFAULT_DB_PASSWORD`/"postgres" when + * unset) — matches Go's `NewContainerConfig`, which sources `POSTGRES_PASSWORD` from the SAME + * `utils.Config.Db.Password` for both the real local container and the shadow + * (`CreateShadowDatabase` reuses `NewContainerConfig` verbatim, `diff.go:140`). Must be threaded + * through so the shadow's actual Postgres password matches what + * `legacyShadowRunInputFromLocalContainerInputs`'s caller connects with — otherwise a + * non-default `[db] password` authenticates against the wrong secret. + */ + readonly password: string; +} + +/** + * Builds the {@link LegacyStartContainerSpec} for the shadow database container. Port of + * Go's `CreateShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:138-151`) — reuses + * the EXACT SAME `NewContainerConfig` (image/env/healthcheck/entrypoint-script shape) the + * real local `db` container uses, just with {@link LEGACY_SHADOW_ENTRYPOINT_ARGS} spliced + * into the entrypoint and a materially different `container.HostConfig`/networking: + * + * - **Empty `containerName`** (Go passes `""` to `DockerStart`, letting Docker + * auto-generate one) — see {@link LegacyStartContainerSpec.containerName}'s own doc + * comment for how the arg-builder and secret-file staging handle this. + * - **`autoRemove: true`** — Go's `hostConfig.AutoRemove` (`--rm`). + * - **No volume bind** — the shadow is throwaway; Go's `hostConfig` sets no `Binds` at all. + * - **No `restartPolicy`** — Go's `hostConfig` sets no `RestartPolicy` either. + * - **No `networkAliases`** — Go's `networkingConfig` is a bare, empty + * `network.NetworkingConfig{}` (no `db`/`db.supabase.internal` aliases). The shadow + * still joins the network via `DockerStart`'s own default `NetworkMode` (confirmed + * empirically: Docker's embedded DNS resolves a container on a user-defined network by + * BOTH its auto-generated name and its 12-char short container id, with no alias + * needed — see `shadow-database.ts`'s header for why this matters). + * - **Tmpfs on PG <= 14 IS still applied** — same `isPg14OrEarlier` condition as the real + * `db` container. + * - **The pgsodium root key `secretFiles` entry is still applied on PG >= 15** — the + * shadow's entrypoint script is the SAME `legacyPostgresEntrypointScriptPg15`, which + * still heredocs it in Go (splice point unaffected by `args`), so this port still needs + * it delivered before `docker start` — via `docker cp` straight into the container + * (`container-lifecycle.ts`), same as every other container's `secretFiles`, never a + * host temp file. + * - **Labels ARE still applied** (merged in by `legacyCreateContainer`, same as every + * other container) so `supabase stop`'s label-filtered sweep catches an orphaned shadow + * too — Go's `DockerStart` sets `CliProjectLabel`/`composeProjectLabel` unconditionally, + * regardless of the `container.Config` literal passed in. `legacyCreateContainer`'s + * caller must also supply {@link LegacyContainerOpts.secretDirId} for the shadow case + * (empty `containerName`) — a randomized fallback identifier so this same sweep can + * still recognize the orphan even without a stable name (see that field's own doc + * comment). + */ +export function legacyBuildShadowPostgresContainerSpec( + input: LegacyShadowPostgresContainerSpecInput, +): LegacyStartContainerSpec { + const rootKeyValue = input.rootKey ?? LEGACY_POSTGRES_DEFAULT_ROOT_KEY; + const postgresConfig = legacyPostgresSettingsToPostgresConfig(input.db.settings); + const isPg14OrEarlier = input.db.major_version <= 14; + + const env: Record = { + POSTGRES_PASSWORD: input.password, + POSTGRES_HOST: "/var/run/postgresql", + JWT_SECRET: input.jwtSecret, + JWT_EXP: String(input.jwtExpiry), + ...legacyPostgresExtraEnv(input.experimental, input.configImage), + }; + + const script = isPg14OrEarlier + ? legacyPostgresEntrypointScriptPg14(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS) + : legacyPostgresEntrypointScriptPg15(postgresConfig, LEGACY_SHADOW_ENTRYPOINT_ARGS); + + return { + image: input.image, + containerName: "", + env, + entrypoint: "sh", + cmd: ["-c", script], + binds: [], + autoRemove: true, + ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), + ...(isPg14OrEarlier + ? {} + : { + secretFiles: [ + { containerPath: LEGACY_POSTGRES_PGSODIUM_ROOT_KEY_PATH, content: rootKeyValue }, + ], + }), + ports: [{ hostPort: String(input.shadowPort), containerPort: "5432" }], + healthcheck: { + test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], + intervalSeconds: LEGACY_POSTGRES_HEALTHCHECK_INTERVAL_SECONDS, + timeoutSeconds: LEGACY_POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS, + retries: LEGACY_POSTGRES_HEALTHCHECK_RETRIES, + }, + networkId: input.networkId, + labels: {}, + }; +} diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts index 3b88393d98..2864609075 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts @@ -7,11 +7,14 @@ import { LEGACY_START_DB_SUPABASE_SQL } from "./templates/db-supabase.sql.ts"; import { LEGACY_START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY } from "../legacy-local-config-values.ts"; import { + LEGACY_SHADOW_ENTRYPOINT_ARGS, legacyBuildPostgresStartContainerSpec, + legacyBuildShadowPostgresContainerSpec, legacyPostgresImageVersionTag, legacyPostgresSettingsToPostgresConfig, legacyPostgresVersionCompare, type LegacyPostgresStartServiceInput, + type LegacyShadowPostgresContainerSpecInput, } from "./postgres.service.ts"; const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; @@ -377,3 +380,75 @@ describe("legacyPostgresImageVersionTag", () => { expect(legacyPostgresImageVersionTag("supabase/postgres")).toBe("supabase/postgres"); }); }); + +function baseShadowInput( + overrides: Partial = {}, +): LegacyShadowPostgresContainerSpecInput { + return { + db: { major_version: 17, settings: {} }, + experimental: baseExperimental(), + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + networkId: "supabase_network_myproj", + image: "public.ecr.aws/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + shadowPort: 54320, + password: "postgres", + ...overrides, + }; +} + +describe("legacyBuildShadowPostgresContainerSpec", () => { + test("PG >= 15: splices the shadow entrypoint args into the SAME trailing-space join point the real db container uses, and still carries the pgsodium root key as a secretFile", () => { + const spec = legacyBuildShadowPostgresContainerSpec( + baseShadowInput({ db: { major_version: 17, settings: {} } }), + ); + const script = spec.cmd?.[1]; + expect(script).toContain( + `docker-entrypoint.sh postgres -D /etc/postgresql ${LEGACY_SHADOW_ENTRYPOINT_ARGS}\n`, + ); + expect(spec.secretFiles).toEqual([ + { + containerPath: "/etc/postgresql-custom/pgsodium_root.key", + content: LEGACY_POSTGRES_DEFAULT_ROOT_KEY, + }, + ]); + expect(spec.tmpfs).toBeUndefined(); + }); + + test("PG <= 14: splices the same args, no pgsodium secretFile, and sets the initdb tmpfs mount", () => { + const spec = legacyBuildShadowPostgresContainerSpec( + baseShadowInput({ db: { major_version: 14, settings: {} } }), + ); + const script = spec.cmd?.[1]; + expect(script).toContain( + `docker-entrypoint.sh postgres -D /etc/postgresql ${LEGACY_SHADOW_ENTRYPOINT_ARGS}\n`, + ); + expect(spec.secretFiles).toBeUndefined(); + expect(spec.tmpfs).toEqual({ "/docker-entrypoint-initdb.d": "" }); + }); + + test("has no name (Docker auto-generates one), no network aliases, no volume bind, and no restart policy — unlike the real db container", () => { + const spec = legacyBuildShadowPostgresContainerSpec(baseShadowInput()); + expect(spec.containerName).toBe(""); + expect(spec.networkAliases).toBeUndefined(); + expect(spec.binds).toEqual([]); + expect(spec.restartPolicy).toBeUndefined(); + }); + + test("sets autoRemove and publishes the shadow port to 5432/tcp", () => { + const spec = legacyBuildShadowPostgresContainerSpec(baseShadowInput({ shadowPort: 54399 })); + expect(spec.autoRemove).toBe(true); + expect(spec.ports).toEqual([{ hostPort: "54399", containerPort: "5432" }]); + }); + + test("labels are still applied (empty map here — the caller merges project/compose labels in, same as every other container)", () => { + const spec = legacyBuildShadowPostgresContainerSpec(baseShadowInput()); + expect(spec.labels).toEqual({}); + }); + + test("initializes POSTGRES_PASSWORD from the resolved [db] password, not a hardcoded literal — matching Go's NewContainerConfig, which sources it from utils.Config.Db.Password for both the real container and the shadow", () => { + const spec = legacyBuildShadowPostgresContainerSpec(baseShadowInput({ password: "hunter2" })); + expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts new file mode 100644 index 0000000000..144840d645 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -0,0 +1,584 @@ +/** + * Native TypeScript port of Go's shadow-database provisioning primitives + * (`apps/cli-go/internal/db/diff/diff.go:138-209`) — CLI-1956. These are the low-level + * building blocks; `legacyPrepareRawShadow` below (create -> health-wait, no platform + * baseline) is one of the two composed shapes `db diff`/`db pull` actually call (Go's + * `PrepareRawShadow`, `apps/cli-go/internal/db/diff/shadow.go:93-116`) — it has zero + * pg-delta/declarative dependency, so it lives here rather than in + * `commands/db/shared/legacy-shadow-source.ts`, which owns the OTHER composed shape + * (`legacyPrepareShadowSource`, Go's `PrepareShadowSource`) precisely because that one also + * needs the `--target-local` declarative-schema branch and pg-delta, which this module — + * deliberately kept dependency-light, like every other `shared/db-bootstrap/` module — does + * not. + * + * Exposed separately (not fused into one monolithic function) because the composed shapes + * 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. + * + * 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 + * (Docker auto-generates one) and NO network alias (`legacyBuildShadowPostgresContainerSpec`), + * unlike every other container this codebase creates. The PG15+ one-shot setup jobs + * (`legacySetupDatabase` -> `initSchema15`) still need SOME hostname to reach it over the + * shared Docker network, though — Go passes `container[:12]` (the container id's own 12-char + * short form) as that hostname (`diff.go:172`, `squash.go:96`). This was verified empirically + * against a real Docker daemon (matching Go's exact container-creation shape: no `--name`, no + * `--network-alias`, joined to a user-defined network via `NetworkMode` alone): `docker + * inspect`'s `NetworkSettings.Networks..DNSNames` lists BOTH the auto-generated name AND + * the 12-char short id, and a sibling container on the same network successfully resolved and + * authenticated against Postgres using ONLY the short id as hostname. So `dbHost: + * container.slice(0, 12)` below is not a guess — it is the exact mechanism Go itself relies on. + */ + +import { randomUUID } from "node:crypto"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { Data, Effect, Schedule, type FileSystem, type Path, type Scope } from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { Output } from "../../../shared/output/output.service.ts"; +import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { + collectText, + legacyDescribeContainerCliFailure, + legacyIsContainerNotFoundMessage, + spawnContainerCli, +} from "../legacy-container-cli.ts"; +import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; +import type { LegacyPgConnInput } from "../legacy-db-connection.service.ts"; +import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; +import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; +import { legacyApplyMigrations } from "../legacy-migration-apply.ts"; +import { + legacyEnsureNetwork, + legacyCreateContainer, + LEGACY_COMPOSE_PROJECT_LABEL, + type LegacyContainerOpts, +} from "./container-lifecycle.ts"; +import type { LegacyImagePrepullError } from "./image-prepull.ts"; +import type { LegacyHealthCheckTimeoutError } from "./health-check.ts"; +import { legacyWaitForHealthyServices } from "./health-check.ts"; +import { legacyListLocalMigrationPaths } from "../legacy-migration-history.ts"; +import { legacyToPostgresURL } from "../legacy-postgres-url.ts"; +import { + type LegacyFreshDbSetupInput, + type LegacySetupDatabaseInput, + type LegacyStartDbSetupImages, + type LegacyStartSetupLocalDatabaseError, + legacyResolveDbSetupPrelude, + legacySetupDatabase, +} from "./db-setup.ts"; +import { + LEGACY_SHADOW_ENTRYPOINT_ARGS, + legacyBuildShadowPostgresContainerSpec, + type LegacyShadowPostgresContainerSpecInput, +} from "./postgres.service.ts"; + +// Re-exported for convenience — the entrypoint-args constant lives on `postgres.service.ts` +// alongside the container-spec builder it feeds, but it documents THIS module's own Go +// citation (`CreateShadowDatabase`, `diff.go:140`) just as much. +export { LEGACY_SHADOW_ENTRYPOINT_ARGS }; + +type Spawner = ChildProcessSpawner["Service"]; + +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + +/** + * Creating, connecting to, setting up, or migrating the shadow database failed. Kept in + * `shared/db-bootstrap/` (not `commands/db/shared/legacy-pgdelta.errors.ts`'s + * `LegacyDeclarativeShadowDbError`) so these primitives stay usable by future callers outside + * the `db diff`/`db pull` family (`migration squash`, `db diff --use-pgadmin`) without pulling + * in a pg-delta-family-specific error type — see this module's own header. + */ +export class LegacyShadowDbError extends Data.TaggedError("LegacyShadowDbError")<{ + readonly message: string; +}> {} + +/** + * Required to bypass the pg_cron check + * (https://github.com/citusdata/pg_cron/blob/main/pg_cron.sql#L3). Go's `CREATE_TEMPLATE` + * (`apps/cli-go/internal/db/diff/diff.go:164`). + */ +export const LEGACY_SHADOW_CREATE_TEMPLATE_SQL = + "CREATE DATABASE contrib_regression TEMPLATE postgres"; + +/** + * Go's `ConnectShadowDatabase`'s fixed timeout — 10 seconds, EVERY real Go caller + * (`apps/cli-go/internal/db/diff/diff.go:187,200`, `internal/migration/squash/squash.go:91`) + * passes the same `10*time.Second` literal. + */ +export const LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS = 10; + +/** + * Go's `NewBackoffPolicy(ctx, timeout)` (`apps/cli-go/internal/db/start/start.go:192-198`): a + * 1-second constant delay, capped at `timeout` (in whole seconds) retries after the initial + * attempt. + */ +const LEGACY_SHADOW_CONNECT_SCHEDULE = Schedule.max([ + Schedule.spaced("1 seconds"), + Schedule.recurs(LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS), +]); + +/** + * Port of Go's `ConnectShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:153-161`): a + * SECOND, independent connect-retry loop layered ON TOP OF the container health wait the + * caller already ran (`start.WaitForHealthyService`) — a healthy Postgres healthcheck doesn't + * guarantee the very next connection attempt succeeds instantly, so Go retries the connect + * itself too, constant 1s backoff, up to {@link LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS} retries. + * Scoped: the returned session's connection closes when the caller's scope closes, matching + * Go's `defer conn.Close(context.Background())` at each real call site. + */ +export const legacyConnectShadowDatabase = ( + cfg: LegacyPgConnInput, +): Effect.Effect => + Effect.gen(function* () { + const dbConnection = yield* LegacyDbConnection; + return yield* dbConnection.connect(cfg, { isLocal: true, dnsResolver: "native" }).pipe( + Effect.mapError((cause) => new LegacyShadowDbError({ message: cause.message })), + Effect.retry({ schedule: LEGACY_SHADOW_CONNECT_SCHEDULE }), + ); + }); + +/** + * Input to {@link legacyCreateShadowDatabase} — the subset of the real `db` container's own + * bootstrap inputs the shadow variant needs, plus its own host port. See + * {@link LegacyShadowPostgresContainerSpecInput} (the container-spec shape this wraps) for + * the field-by-field Go citations. + */ +export interface LegacyCreateShadowDatabaseInput extends LegacyShadowPostgresContainerSpecInput { + /** Go's `Config.ProjectId` — merged onto the shadow's own labels (`DockerStart`'s unconditional label assignment) and the network-create call, matching every other container this codebase creates. */ + readonly projectId: string; + readonly isBitbucketPipeline: boolean; + readonly workdir: string; + readonly extraHosts: ReadonlyArray; +} + +/** Resolved by {@link legacyCreateShadowDatabase} — everything a caller needs to both use and later tear down the shadow. */ +export interface LegacyShadowDatabaseHandle { + /** Docker always returns the id from `docker create`, regardless of whether `--name` was passed. */ + readonly containerId: string; + /** See {@link legacyCreateShadowDatabase}'s own doc comment for why this is generated per-call rather than fixed. Threaded through by the caller to {@link legacyRemoveShadowDatabase} so the staged secret directory (see {@link LegacyContainerOpts.secretDirId}) is reclaimed at teardown. */ + readonly secretDirId: string; +} + +/** + * Port of Go's `CreateShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:138-151`): + * ensures the local Docker network exists (Go's `DockerStart` calls + * `DockerNetworkCreateIfNotExists` on EVERY invocation, unlike the `start`/`reset` + * compositions, which hoist this to run once per orchestrated run — `db diff`/`db pull` have + * no such orchestrator, so this mirrors Go's own per-call behavior instead), then creates + + * starts the shadow container. + */ +export const legacyCreateShadowDatabase = ( + spawner: Spawner, + input: LegacyCreateShadowDatabaseInput, +): Effect.Effect => + Effect.gen(function* () { + const labels = { + [LEGACY_CLI_PROJECT_LABEL]: input.projectId, + [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, + }; + yield* legacyEnsureNetwork(spawner, input.networkId, labels).pipe( + Effect.mapError((cause) => new LegacyShadowDbError({ message: cause.message })), + ); + const spec = legacyBuildShadowPostgresContainerSpec(input); + // The shadow container has no name (Docker auto-generates one), so it can't be found by + // `stop`'s project-label-filtered orphan sweep the way a named container can. Stamp a + // randomized fallback identifier (`LEGACY_CLI_SECRET_DIR_LABEL`, `container-lifecycle.ts`) + // onto it instead, so an orphaned shadow (this process killed before + // {@link legacyRemoveShadowDatabase} ever runs) can still be recognized and removed later. + // The pgsodium root key itself (PG15+ only) never touches host disk at all — it's + // delivered straight into the container via `docker cp` + // ({@link LegacyStartContainerSpec.secretFiles}, `container-lifecycle.ts`), same as every + // other container's secrets. Randomized rather than a fixed `"shadow"` string purely so + // two concurrent `db diff`/`db pull` runs in the same workdir don't stamp identical labels. + const secretDirId = `shadow-${randomUUID()}`; + const containerOpts: LegacyContainerOpts = { + projectId: input.projectId, + isBitbucketPipeline: input.isBitbucketPipeline, + workdir: input.workdir, + extraHosts: input.extraHosts, + secretDirId, + }; + const containerId = yield* legacyCreateContainer(spawner, spec, containerOpts).pipe( + Effect.mapError((cause) => new LegacyShadowDbError({ message: cause.message })), + ); + return { containerId, secretDirId }; + }); + +/** Input to {@link legacyRemoveShadowDatabase} — everything needed to tear down both halves of a shadow ({@link legacyCreateShadowDatabase}'s container AND its staged secret directory). */ +export interface LegacyRemoveShadowDatabaseInput { + readonly containerId: string; + /** {@link LegacyShadowDatabaseHandle.secretDirId} — see {@link legacyCreateShadowDatabase}'s own doc comment. Threaded through so {@link legacyCleanupShadowSecretDir} can reclaim a legacy `/supabase/.temp/start-secrets//` directory left over from before secrets moved to `docker cp` delivery, if one somehow still exists. */ + readonly secretDirId: string; + readonly workdir: string; +} + +/** + * Best-effort `rm -rf` of a LEGACY `/supabase/.temp/start-secrets//` + * directory — the shadow's pgsodium root key is delivered via `docker cp` now + * ({@link legacyCreateShadowDatabase}'s own doc comment), so this is always a no-op in + * current builds. Kept as a defensive cleanup in case such a directory was staged by an + * older binary and never reclaimed, rather than left to accumulate indefinitely. Never + * fails: a missing directory is already the desired end state, and a real deletion error + * is not worth failing the caller's diff/pull over. + */ +const legacyCleanupShadowSecretDir = ( + secretDirId: string, + workdir: string, +): Effect.Effect => { + if (secretDirId.length === 0) return Effect.void; + return Effect.tryPromise(() => + rm(join(workdir, "supabase", ".temp", "start-secrets", secretDirId), { + recursive: true, + force: true, + }), + ).pipe( + Effect.asVoid, + Effect.orElseSucceed(() => undefined), + ); +}; + +/** + * Port of Go's `utils.DockerRemove(shadow)` as called by every shadow caller + * (`apps/cli-go/internal/db/diff/diff.go:217`, `shadow.go:45,103`, + * `internal/migration/squash/squash.go:87`): `RemoveOptions{RemoveVolumes: true, Force: + * true}` via `docker rm -f -v `. Best-effort for the OVERALL operation — Go's own + * `DockerRemove` swallows the removal's ERROR RETURN (it has no return value at all), so a + * failure here must never mask whatever the caller was doing with the shadow — but it does + * NOT swallow the message: Go prints `"Failed to remove container:", containerId, err` to + * stderr on failure (`apps/cli-go/internal/utils/docker.go:442-449`), so this does the same + * before continuing. That includes a failure to even launch/collect the removal itself (the + * container CLI missing, a disconnected runtime, a stream-read error) — Go's single + * `Docker.ContainerRemove` SDK call folds every one of those causes into the same `err` it + * prints, so this catches {@link spawnContainerCli}/exit-code-collection failures the same way + * {@link legacyRestartSatelliteService} does (`restart-services.ts`), via + * {@link legacyDescribeContainerCliFailure}, rather than discarding them unreported. Also + * reclaims the shadow's staged secret directory (see {@link legacyCleanupShadowSecretDir}) — + * but ONLY once the container is confirmed gone (removal succeeded, or it was already absent), + * never on a genuine removal failure (daemon disconnected, CLI missing, an unrecognized + * error). {@link legacyCreateShadowDatabase}'s own doc comment explains why the secret + * directory (the PG15+ pgsodium root-key bind source) must outlive `docker start` by seconds so + * Postgres's entrypoint can still read it — that same invariant means it must ALSO outlive a + * shadow that `docker rm` failed to actually remove: a still-running (or later-restarted) + * orphan would find its bind source deleted out from under it. `secretDirId` is randomized per + * shadow, not keyed off the container's name, so this reclaim (once safe) is the NORMAL path + * that finds it — the only other path is `legacyCleanupStartSecrets`'s + * `LEGACY_CLI_SECRET_DIR_LABEL` fallback, for when this whole process (not just `docker rm`) + * never got to run at all, e.g. killed mid-flight (review: PRRT_kwDOErm0O86W8ZYt). + */ +export const legacyRemoveShadowDatabase = ( + spawner: Spawner, + input: LegacyRemoveShadowDatabaseInput, +): Effect.Effect => + Effect.gen(function* () { + const { containerId, secretDirId, workdir } = input; + let containerGone = containerId.length === 0; + if (containerId.length > 0) { + const failureMessage = yield* Effect.scoped( + Effect.gen(function* () { + const child = yield* spawnContainerCli(spawner, ["rm", "-f", "-v", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + extendEnv: true, + }); + const [exitCode, stderr] = yield* Effect.all( + [child.exitCode.pipe(Effect.map(Number)), collectText(child.stderr)], + { concurrency: "unbounded" }, + ); + return exitCode === 0 ? undefined : stderr.trim(); + }), + ).pipe(Effect.catch((cause) => Effect.succeed(legacyDescribeContainerCliFailure(cause)))); + if (failureMessage !== undefined) { + const output = yield* Output; + yield* output.raw( + `Failed to remove container: ${containerId} ${failureMessage}\n`, + "stderr", + ); + } + containerGone = + failureMessage === undefined || legacyIsContainerNotFoundMessage(failureMessage); + } + if (containerGone) { + yield* legacyCleanupShadowSecretDir(secretDirId, workdir); + } + }); + +/** A live shadow database left running for the caller to diff against and remove. Mirrors Go's `ShadowSource`. */ +export interface LegacyShadowSourceResult { + /** Container id; the caller MUST remove it (`legacyRemoveShadowDatabase`) when done. */ + readonly container: string; + /** {@link LegacyShadowDatabaseHandle.secretDirId} — the caller MUST also thread this (and the shadow's own `workdir`) into `legacyRemoveShadowDatabase` so the staged secret directory is reclaimed alongside the container. No Go equivalent (Go never stages this on host disk at all). */ + readonly secretDirId: string; + /** The diff source Postgres URL (the provisioned shadow). */ + readonly sourceUrl: string; + /** + * When set, replaces the diff target with a second database on the SAME shadow container + * (`contrib_regression`, cloned from `postgres` by `CREATE_TEMPLATE` during shadow setup — + * see {@link legacySetupShadowConn}) with declarative schemas applied. Mirrors Go's + * local-target declarative branch, where the user's local DB is not diffed. Only ever set + * by `legacy-shadow-source.ts`'s `legacyPrepareShadowSource` — {@link legacyPrepareRawShadow} + * below always leaves this `undefined`. + */ + readonly targetUrlOverride: string | undefined; +} + +/** Fields shared by `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput`/{@link LegacyPrepareRawShadowInput}. */ +export interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseInput { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly hostname: string; + /** `[db] password` (already resolved from `config.toml`) — the shadow's own connect password. */ + readonly password: string; + readonly healthTimeoutSeconds: number; +} + +export type LegacyPrepareRawShadowInput = LegacyShadowConnectionInput; + +/** + * Port of Go's `PrepareRawShadow` (`apps/cli-go/internal/db/diff/shadow.go:93-116`): health-wait + * against an already-{@link legacyCreateShadowDatabase}-created shadow (created + healthy, no + * platform baseline or migrations applied) — used inline (`db pull --declarative`'s empty + * declarative-export source), not the `ok`-sentinel error-path pattern + * `legacy-shadow-source.ts`'s `legacyPrepareShadowSource` uses, since there is only ONE step + * here that can fail (the health wait) rather than several. Lives here (not + * `legacy-shadow-source.ts`) because it has zero pg-delta/declarative dependency — see this + * module's own header. + * + * Deliberately does NOT call {@link legacyCreateShadowDatabase} itself — the caller does, as the + * `acquire` of an `Effect.acquireUseRelease` whose `use` phase is this function (see + * `diff.handler.ts`/`pull.handler.ts`'s call sites). Go's `PrepareRawShadow` threads a single + * cancellable `ctx` through both creation and the health wait, so a SIGINT can interrupt either; + * an earlier shape here instead passed the WHOLE create-then-health-wait effect as `acquire`, + * which Effect's `uninterruptibleMask` (`acquireUseRelease(acquire, use, release) => + * uninterruptibleMask(restore => flatMap(acquire, a => onExitPrimitive(restore(use(a)), ...)))`) + * makes entirely uninterruptible — a SIGINT during the health wait (which can run for up to + * `healthTimeoutSeconds`) was silently swallowed until the wait finished or timed out on its + * own, unlike Go. Splitting `legacyCreateShadowDatabase` out as the (brief, Docker-API-bound) + * `acquire` and keeping this health-wait as part of the interruptible `use` restores that parity + * — a SIGINT here now lands immediately, same as Go's ctx cancellation, while + * `legacyRemoveShadowDatabase` still runs as the `release` finalizer regardless of how `use` + * exits (review: PRRT_kwDOErm0O86XMrID). + */ +export const legacyPrepareRawShadow = ( + spawner: Spawner, + handle: LegacyShadowDatabaseHandle, + input: LegacyPrepareRawShadowInput, +): Effect.Effect< + LegacyShadowSourceResult, + LegacyHealthCheckTimeoutError, + Output | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient +> => + Effect.gen(function* () { + const { containerId, secretDirId } = handle; + yield* legacyWaitForHealthyServices(spawner, [containerId], { + timeoutSeconds: input.healthTimeoutSeconds, + }); + const connConfig: LegacyPgConnInput = { + host: input.hostname, + port: input.shadowPort, + user: "postgres", + password: input.password, + database: "postgres", + }; + return { + container: containerId, + secretDirId, + sourceUrl: legacyToPostgresURL(connConfig), + targetUrlOverride: undefined, + }; + }); + +/** + * Port of Go's `setupShadowConn` (`apps/cli-go/internal/db/diff/diff.go:171-179`): + * {@link legacySetupDatabase} (Go's `SetupDatabase`) against an already-connected shadow, + * dialed at `input.dbHost` = `container.slice(0, 12)` (see this module's own header), then + * optionally {@link LEGACY_SHADOW_CREATE_TEMPLATE_SQL}. `withTemplate` is `true` for every + * real Go caller of `setupShadowConn` itself (`SetupShadowDatabase`/`MigrateShadowDatabase` + * below); exposed as a parameter (not hardcoded) so a future caller that only needs the bare + * `SetupDatabase` step (`migration squash`, which calls `start.SetupDatabase` DIRECTLY, + * bypassing `setupShadowConn` entirely — `squash.go:96`) can call {@link legacySetupDatabase} + * on its own instead, while this function stays the exact `setupShadowConn` shape. + */ +export const legacySetupShadowConn = ( + spawner: Spawner, + input: LegacySetupDatabaseInput, + withTemplate: boolean, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyShadowDbError, + Output | LegacyDockerRun | RuntimeInfo +> => + Effect.gen(function* () { + yield* legacySetupDatabase(spawner, input); + if (!withTemplate) return; + yield* input.session.exec(LEGACY_SHADOW_CREATE_TEMPLATE_SQL).pipe( + Effect.mapError( + (cause) => + new LegacyShadowDbError({ + message: `failed to create template database: ${errMessage(cause)}`, + }), + ), + ); + }); + +/** + * Shared fields both {@link legacySetupShadowDatabase} and {@link legacyMigrateShadowDatabase} + * need to resolve JWKS/images and run {@link legacySetupDatabase} — derived from `db-setup.ts`'s + * `LegacyFreshDbSetupInput` (the exact same shape `legacyRunFreshDbSetup` resolves for the real + * local `db` container) rather than hand-copied, so the two never silently drift: swap + * `experimental` (which only `legacyStartSetupLocalDatabase`'s trailing `MigrateAndSeed` call + * needs — irrelevant to the shadow's `SetupDatabase`-only pipeline, see {@link + * LegacySetupDatabaseInput}'s own doc comment) for the two fields the shadow's own caller + * (`legacy-shadow-source.ts`) resolves from an already-loaded `config.toml` instead + * (`apiAutoExposeNewTables`/`vault`), threaded straight through here rather than re-read. + */ +export type LegacyShadowDbSetupInput = Omit, "experimental"> & { + readonly apiAutoExposeNewTables: LegacySetupDatabaseInput["apiAutoExposeNewTables"]; + readonly vault: LegacySetupDatabaseInput["vault"]; +}; + +/** Common caller-supplied plumbing for {@link legacySetupShadowDatabase}/{@link legacyMigrateShadowDatabase}. */ +interface LegacyShadowSetupRunInput { + readonly fs: FileSystem.FileSystem; + readonly path: Path.Path; + readonly workdir: string; + /** Go's `Config.ProjectId` — labels the shadow's own PG15+ one-shot migrate job containers, same as the real local `db` container's — see {@link LegacySetupDatabaseInput.projectId}'s own doc comment. */ + readonly projectId: string; + readonly container: string; + readonly networkId: string; + /** The shadow's own connect target — host/port/user/password/database (`postgres`/`postgres`). */ + readonly connConfig: LegacyPgConnInput; + readonly setup: LegacyShadowDbSetupInput; +} + +/** + * Builds a {@link LegacySetupDatabaseInput} for {@link legacySetupDatabase} out of an + * already-connected shadow session plus the resolved images/JWKS prelude — exported so a + * future caller that only needs `SetupDatabase` directly (`migration squash`, which calls + * Go's `start.SetupDatabase` without going through `setupShadowConn` at all — see {@link + * legacySetupShadowConn}'s own doc comment) can build this same shape without duplicating the + * `container[:12]` dbHost derivation. + */ +export const legacyBuildShadowSetupDatabaseInput = ( + input: LegacyShadowSetupRunInput, + session: LegacyDbSession, + resolved: { readonly jwks: string; readonly images: LegacyStartDbSetupImages }, +): LegacySetupDatabaseInput => ({ + session, + fs: input.fs, + path: input.path, + workdir: input.workdir, + config: input.setup.config, + majorVersion: input.setup.majorVersion, + // Go's `container[:12]` — see this module's own header for why this resolves as a + // hostname at all despite the shadow container having no name/alias. + dbHost: input.container.slice(0, 12), + projectId: input.projectId, + networkId: input.networkId, + dbUrl: input.setup.dbUrl, + jwtSecret: input.setup.jwtSecret, + jwks: resolved.jwks, + apiUrl: input.setup.apiUrl, + authExternalUrl: input.setup.authExternalUrl, + siteUrl: input.setup.siteUrl, + anonKey: input.setup.anonKey, + serviceRoleKey: input.setup.serviceRoleKey, + storageTargetMigration: input.setup.storageTargetMigration, + images: resolved.images, + projectEnvValues: input.setup.projectEnvValues, + debug: input.setup.debug, + apiAutoExposeNewTables: input.setup.apiAutoExposeNewTables, + vault: input.setup.vault, +}); + +/** + * Port of Go's `SetupShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:181-193`): + * connects to the shadow (Go's `ConnectShadowDatabase`, {@link legacyConnectShadowDatabase}) + * FIRST, THEN resolves the setup prelude (JWKS/pinned image names, {@link + * legacyResolveDbSetupPrelude}) and runs {@link legacySetupShadowConn} WITH the template + * database — the platform baseline only, no user migrations. Connect-then-setup, matching Go's + * own `SetupShadowDatabase` (which dials `ConnectShadowDatabase` before ever calling + * `start.SetupDatabase`, `diff.go:186-192`) and this same module's `legacyRunFreshDbSetup` + * (`db-setup.ts`) for the real local `db` container: an unconnectable shadow must surface a + * connect error immediately, not pay for JWKS work first. The connection is closed once this + * resolves (Go's `defer conn.Close(...)`), matching `Effect.scoped`'s finalizer running at the + * end of this function rather than leaking a `Scope.Scope` requirement to the caller. + */ +export const legacySetupShadowDatabase = ( + spawner: Spawner, + input: LegacyShadowSetupRunInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, + Output | LegacyDockerRun | RuntimeInfo | LegacyDbConnection +> => + Effect.scoped( + Effect.gen(function* () { + const session = yield* legacyConnectShadowDatabase(input.connConfig); + const resolved = yield* legacyResolveDbSetupPrelude(input.setup); + yield* legacySetupShadowConn( + spawner, + legacyBuildShadowSetupDatabaseInput(input, session, resolved), + true, + ); + }), + ); + +/** + * Port of Go's `MigrateShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:195-209`): + * lists local migrations FIRST (Go's `migration.ListLocalMigrations`, fails fast on a bad + * migrations directory before any DB connection is even attempted), THEN connects (Go's + * `ConnectShadowDatabase`), THEN resolves the setup prelude (JWKS/pinned image names, {@link + * legacyResolveDbSetupPrelude}) and sets up the platform baseline + template database ({@link + * legacySetupShadowConn}, `withTemplate: true`), then applies every listed migration (Go's + * `migration.ApplyMigrations`). Connect-then-setup (not the reverse) matches Go's own + * `MigrateShadowDatabase` (`diff.go:195-209`) and this same module's `legacyRunFreshDbSetup` + * (`db-setup.ts`) for the real local `db` container — see {@link legacySetupShadowDatabase}'s + * own doc comment for why the ordering matters. Connection closed once this resolves, matching + * Go's `defer conn.Close(...)`. + */ +export const legacyMigrateShadowDatabase = ( + spawner: Spawner, + input: LegacyShadowSetupRunInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, + Output | LegacyDockerRun | RuntimeInfo | LegacyDbConnection +> => + Effect.scoped( + Effect.gen(function* () { + const migrationsDir = input.path.join(input.workdir, "supabase", "migrations"); + const pending = yield* legacyListLocalMigrationPaths( + input.fs, + input.path, + migrationsDir, + ).pipe(Effect.mapError((cause) => new LegacyShadowDbError({ message: cause.message }))); + + const session = yield* legacyConnectShadowDatabase(input.connConfig); + const resolved = yield* legacyResolveDbSetupPrelude(input.setup); + yield* legacySetupShadowConn( + spawner, + legacyBuildShadowSetupDatabaseInput(input, session, resolved), + true, + ); + yield* legacyApplyMigrations( + session, + input.fs, + input.path, + pending, + (message) => new LegacyShadowDbError({ message }), + ); + }), + ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts new file mode 100644 index 0000000000..7b04d4fb01 --- /dev/null +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts @@ -0,0 +1,725 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ProjectConfig } from "@supabase/config"; +import { ProjectConfigSchema } from "@supabase/config"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path, Schema, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +import type { LegacyDbSession } from "../legacy-db-connection.service.ts"; +import { LegacyDbConnection } from "../legacy-db-connection.service.ts"; +import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; +import type { LegacySetupDatabaseInput } from "./db-setup.ts"; +import { + LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS, + LEGACY_SHADOW_CREATE_TEMPLATE_SQL, + LEGACY_SHADOW_ENTRYPOINT_ARGS, + legacyBuildShadowSetupDatabaseInput, + legacyConnectShadowDatabase, + legacyCreateShadowDatabase, + legacyMigrateShadowDatabase, + legacyRemoveShadowDatabase, + legacySetupShadowConn, + legacySetupShadowDatabase, + type LegacyCreateShadowDatabaseInput, + type LegacyShadowDbSetupInput, +} from "./shadow-database.ts"; + +const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const defaultConfig: ProjectConfig = decodeConfig({}); + +function fakeSession() { + const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; + const session: LegacyDbSession = { + exec: (sql) => + Effect.sync(() => { + calls.push({ kind: "exec", sql }); + }), + query: (sql) => + Effect.sync(() => { + calls.push({ kind: "query", sql }); + return []; + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return { session, calls }; +} + +function mockDbConnection(session: LegacyDbSession) { + return Layer.succeed(LegacyDbConnection, { connect: () => Effect.succeed(session) }); +} + +function mockDockerRun() { + const runs: Array = []; + return Layer.succeed(LegacyDockerRun, { + run: () => Effect.succeed(0), + runCapture: (runOpts) => { + runs.push(runOpts); + return Effect.succeed({ exitCode: 0, stdout: new Uint8Array(), stderr: "" }); + }, + // The shadow's own PG15+ one-shot platform-baseline jobs (`legacyRunStartMigrateJob`) + // go through `runStream`, not `runCapture` — see `db-setup.ts`'s own doc comment. + runStream: (runOpts) => { + runs.push(runOpts); + return Effect.succeed({ exitCode: 0, stderr: "" }); + }, + }); +} + +/** Fakes `docker image inspect` (always cached), `network create`, `create` (returns a fixed id), `start`, and `rm`. */ +function mockSpawner() { + const spawned: Array> = []; + const encoder = new TextEncoder(); + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + const stdout = args[0] === "create" ? "shadow-container-id-0123456789abcdef" : ""; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.fromIterable(stdout.length > 0 ? [encoder.encode(`${stdout}\n`)] : []), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { spawner, spawned }; +} + +/** Fakes `docker rm` with a caller-controlled exit code/stderr, for the failure-path tests below. */ +function mockRmSpawner(result: { exitCode: number; stderr?: string }) { + const spawned: Array> = []; + const encoder = new TextEncoder(); + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.fromIterable( + result.stderr !== undefined ? [encoder.encode(result.stderr)] : [], + ), + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { spawner, spawned }; +} + +function baseCreateInput( + overrides: Partial = {}, +): LegacyCreateShadowDatabaseInput { + return { + db: { major_version: 17, settings: {} }, + experimental: defaultConfig.experimental, + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwtExpiry: 3600, + networkId: "supabase_network_proj", + image: "public.ecr.aws/supabase/postgres:17.4.1.030", + configImage: "supabase/postgres:17.4.1.030", + shadowPort: 54320, + password: "postgres", + projectId: "proj", + isBitbucketPipeline: false, + workdir: mkdtempSync(join(tmpdir(), "legacy-shadow-database-")), + extraHosts: [], + ...overrides, + }; +} + +describe("legacyCreateShadowDatabase / legacyRemoveShadowDatabase", () => { + it.effect( + "creates the network then the container with no --name, and returns the created id + a fresh secretDirId", + () => { + const mock = mockSpawner(); + return legacyCreateShadowDatabase(mock.spawner, baseCreateInput()).pipe( + Effect.map(({ containerId, secretDirId }) => { + expect(containerId).toBe("shadow-container-id-0123456789abcdef"); + expect(secretDirId).toMatch(/^shadow-/); + const networkCreateIdx = mock.spawned.findIndex((a) => a[0] === "network"); + const createIdx = mock.spawned.findIndex((a) => a[0] === "create"); + expect(networkCreateIdx).toBeGreaterThanOrEqual(0); + expect(networkCreateIdx).toBeLessThan(createIdx); + expect(mock.spawned[createIdx]).not.toContain("--name"); + expect(mock.spawned[createIdx]).toContain("--rm"); + }), + ); + }, + ); + + it.effect("legacyRemoveShadowDatabase issues docker rm -f -v against the given id", () => { + const mock = mockSpawner(); + return legacyRemoveShadowDatabase(mock.spawner, { + containerId: "shadow-container-id-0123456789abcdef", + secretDirId: "", + workdir: "/proj", + }).pipe( + Effect.map(() => { + expect(mock.spawned).toContainEqual([ + "rm", + "-f", + "-v", + "shadow-container-id-0123456789abcdef", + ]); + }), + Effect.provide(mockOutput().layer), + ); + }); + + it.effect( + "legacyRemoveShadowDatabase is a pure no-op (no spawn at all) for an empty container id", + () => { + const mock = mockSpawner(); + return legacyRemoveShadowDatabase(mock.spawner, { + containerId: "", + secretDirId: "", + workdir: "/proj", + }).pipe( + Effect.map(() => { + expect(mock.spawned).toEqual([]); + }), + Effect.provide(mockOutput().layer), + ); + }, + ); + + it.effect( + "legacyRemoveShadowDatabase rm -rf's the staged secret directory keyed off secretDirId", + () => { + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockSpawner(); + const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "shadow-abc123"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "root-key"); + yield* legacyRemoveShadowDatabase(mock.spawner, { + containerId: "shadow-container-id-0123456789abcdef", + secretDirId: "shadow-abc123", + workdir, + }); + const stillExists = yield* fs.exists(secretDir); + expect(stillExists).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))); + }, + ); + + it.effect( + "retains the staged secret directory when docker rm fails for a reason other than the container being absent (review: PRRT_kwDOErm0O86W7n95)", + () => { + // The container might still be alive (daemon disconnected mid-remove, a transient CLI + // error, ...) — deleting the PG15+ pgsodium root-key bind source out from under a + // surviving shadow would break its ability to restart, per `legacyCreateShadowDatabase`'s + // own doc comment on why this directory must outlive `docker start` in the first place. + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockRmSpawner({ + exitCode: 1, + stderr: "Error: Cannot connect to the Docker daemon\n", + }); + const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "shadow-abc123"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "root-key"); + yield* legacyRemoveShadowDatabase(mock.spawner, { + containerId: "shadow-container-id-0123456789abcdef", + secretDirId: "shadow-abc123", + workdir, + }); + const stillExists = yield* fs.exists(secretDir); + expect(stillExists).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))); + }, + ); + + it.effect( + "still reclaims the staged secret directory when docker rm reports the container already absent (review: PRRT_kwDOErm0O86W7n95)", + () => { + // A "No such container" failure from `docker rm` means the container is CONFIRMED gone + // already (e.g. removed by something else, or a race with a prior cleanup) — the shadow + // can't restart if it doesn't exist, so it's safe to reclaim the secret directory here, + // same as the exit-0 success path above. + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockRmSpawner({ + exitCode: 1, + stderr: "Error: No such container: shadow-container-id-0123456789abcdef\n", + }); + const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "shadow-abc123"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "root-key"); + yield* legacyRemoveShadowDatabase(mock.spawner, { + containerId: "shadow-container-id-0123456789abcdef", + secretDirId: "shadow-abc123", + workdir, + }); + const stillExists = yield* fs.exists(secretDir); + expect(stillExists).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))); + }, + ); +}); + +describe("legacyConnectShadowDatabase", () => { + it.effect( + "dials the shadow's own connect config and returns the session on the first successful attempt", + () => { + const { session } = fakeSession(); + return legacyConnectShadowDatabase({ + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }).pipe( + Effect.scoped, + Effect.map((resolvedSession) => { + expect(resolvedSession).toBe(session); + }), + Effect.provide(mockDbConnection(session)), + ); + }, + ); + + it("the retry timeout constant matches Go's fixed 10-second ConnectShadowDatabase literal", () => { + expect(LEGACY_SHADOW_CONNECT_TIMEOUT_SECONDS).toBe(10); + }); +}); + +function baseSetupDatabaseInput( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, +): LegacySetupDatabaseInput { + return { + session, + fs, + path, + workdir, + config: defaultConfig, + majorVersion: 17, + dbHost: "abcdef012345", + projectId: "proj", + networkId: "supabase_network_proj", + dbUrl: "postgresql://postgres:postgrespassword@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: '{"keys":[]}', + apiUrl: "http://127.0.0.1:54321", + siteUrl: defaultConfig.auth.site_url, + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + storageTargetMigration: "", + images: { + realtime: "public.ecr.aws/supabase/realtime:v2.34.7", + storage: "public.ecr.aws/supabase/storage-api:v1.0.0", + auth: "public.ecr.aws/supabase/gotrue:v2.170.0", + }, + projectEnvValues: undefined, + debug: false, + apiAutoExposeNewTables: Option.some(true), + vault: [], + }; +} + +describe("legacySetupShadowConn", () => { + it.effect("runs SetupDatabase, then execs CREATE_TEMPLATE when withTemplate is true", () => { + const { session, calls } = fakeSession(); + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockSpawner(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySetupShadowConn( + mock.spawner, + baseSetupDatabaseInput(session, fs, path, workdir), + true, + ); + expect(calls.some((c) => c.sql === LEGACY_SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll(BunServices.layer, mockOutput().layer, mockDockerRun(), mockRuntimeInfo()), + ), + ); + }); + + it.effect("skips CREATE_TEMPLATE when withTemplate is false", () => { + const { session, calls } = fakeSession(); + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockSpawner(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySetupShadowConn( + mock.spawner, + baseSetupDatabaseInput(session, fs, path, workdir), + false, + ); + expect(calls.some((c) => c.sql === LEGACY_SHADOW_CREATE_TEMPLATE_SQL)).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll(BunServices.layer, mockOutput().layer, mockDockerRun(), mockRuntimeInfo()), + ), + ); + }); +}); + +function baseShadowSetup( + overrides: Partial> = {}, +): LegacyShadowDbSetupInput { + return { + majorVersion: 17, + config: defaultConfig, + dbUrl: "postgresql://postgres:postgrespassword@127.0.0.1:54322/postgres", + jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + jwks: Effect.succeed('{"keys":[]}') as Effect.Effect, + apiUrl: "http://127.0.0.1:54321", + authExternalUrl: undefined, + siteUrl: defaultConfig.auth.site_url, + anonKey: "anon-key", + serviceRoleKey: "service-role-key", + storageTargetMigration: "", + realtimeEnabledForSetup: false, + storageEnabledForSetup: false, + authEnabledForSetup: false, + serviceVersionOverrides: {}, + projectEnvValues: undefined, + debug: false, + apiAutoExposeNewTables: Option.some(true), + vault: [], + ...overrides, + }; +} + +describe("legacyBuildShadowSetupDatabaseInput", () => { + it.effect( + "derives dbHost from the container's own 12-char short id and threads every field through", + () => { + const { session } = fakeSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const built = legacyBuildShadowSetupDatabaseInput( + { + fs, + path, + workdir: "/proj", + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup(), + }, + session, + { + jwks: '{"keys":[]}', + images: { + realtime: "public.ecr.aws/supabase/realtime:v2.34.7", + storage: "public.ecr.aws/supabase/storage-api:v1.0.0", + auth: "public.ecr.aws/supabase/gotrue:v2.170.0", + }, + }, + ); + // Go's `container[:12]` — the future callers this was exported for (`migration + // squash`) need this exact same derivation, not a re-implementation. + expect(built.dbHost).toBe("shadow-conta"); + expect(built.session).toBe(session); + expect(built.workdir).toBe("/proj"); + expect(built.networkId).toBe("supabase_network_proj"); + expect(built.majorVersion).toBe(17); + expect(built.jwks).toBe('{"keys":[]}'); + expect(built.images.realtime).toBe("public.ecr.aws/supabase/realtime:v2.34.7"); + expect(built.apiAutoExposeNewTables).toEqual(Option.some(true)); + expect(built.vault).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); +}); + +describe("legacySetupShadowDatabase / legacyMigrateShadowDatabase", () => { + it.effect( + "legacySetupShadowDatabase connects, sets up the platform baseline, and creates the template database", + () => { + const { session, calls } = fakeSession(); + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockSpawner(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySetupShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup(), + }); + expect(calls.some((c) => c.sql === LEGACY_SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }, + ); + + it.effect( + "legacyMigrateShadowDatabase applies pending local migrations after the platform baseline", + () => { + const { session, calls } = fakeSession(); + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockSpawner(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase", "migrations"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "migrations", "20240101000000_init.sql"), + "create table t ();", + ); + yield* legacyMigrateShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup(), + }); + expect(calls.some((c) => c.sql === LEGACY_SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); + expect(calls.some((c) => c.sql.includes("create table t ()"))).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }, + ); + + it.effect( + "does not resolve JWKS on PG14 even when realtime is enabled (Go's initSchema never reaches ResolveJWKS for MajorVersion <= 14)", + () => { + const { session } = fakeSession(); + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockSpawner(); + let jwksEvaluated = false; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySetupShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup({ + majorVersion: 14, + realtimeEnabledForSetup: true, + jwks: Effect.sync(() => { + jwksEvaluated = true; + return '{"keys":[]}'; + }), + }), + }); + expect(jwksEvaluated).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }, + ); + + it.effect("resolves JWKS on PG15+ when realtime is enabled", () => { + const { session } = fakeSession(); + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockSpawner(); + let jwksEvaluated = false; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* legacySetupShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup({ + majorVersion: 17, + realtimeEnabledForSetup: true, + jwks: Effect.sync(() => { + jwksEvaluated = true; + return '{"keys":[]}'; + }), + }), + }); + expect(jwksEvaluated).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }); + + it.effect( + "legacyMigrateShadowDatabase lists local migrations BEFORE connecting, tolerating a missing migrations directory as an empty list rather than a failure", + () => { + const workdir = mkdtempSync(join(tmpdir(), "legacy-shadow-database-")); + const mock = mockSpawner(); + // One shared, ordered log — recording both events into separate booleans (the prior + // version of this test) would still pass if the two steps were swapped, since both + // would still end up `true`; only an ordered log actually proves the sequence. + const events: Array = []; + const dbConnection = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.sync(() => { + events.push("connect"); + return fakeSession().session; + }), + }); + return Effect.gen(function* () { + const realFs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(workdir, "supabase", "migrations"); + const fs = FileSystem.FileSystem.of({ + ...realFs, + readDirectory: (dir, opts) => { + if (dir === migrationsDir) events.push("list"); + return realFs.readDirectory(dir, opts); + }, + }); + // No `supabase/migrations` directory exists — Go's `ListLocalMigrations` on a + // missing dir resolves to an empty list (not an error), so this exercises the + // ordering guarantee (list BEFORE connect) rather than a failure path. + yield* legacyMigrateShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup(), + }); + expect(events).toEqual(["list", "connect"]); + rmSync(workdir, { recursive: true, force: true }); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + dbConnection, + ), + ), + ); + }, + ); +}); + +describe("LEGACY_SHADOW_ENTRYPOINT_ARGS", () => { + it("matches Go's -c max_worker_processes=0 args exactly", () => { + expect(LEGACY_SHADOW_ENTRYPOINT_ARGS).toBe("-c max_worker_processes=0"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 8327137bfd..ab57b6e9ee 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -167,7 +167,7 @@ export function legacyIsContainerNotFoundMessage(message: string): boolean { * failure mode (spawn failure, non-zero exit) — the shared shape behind every * "docker verb target" primitive that fails hard on any problem * (`legacyRemoveContainer`/`legacyRemoveVolume`/`legacyRestartContainer`; see - * `containers/container-lifecycle.ts` and `db-bootstrap/restart-services.ts`). + * `db-bootstrap/container-lifecycle.ts` and `db-bootstrap/restart-services.ts`). * `verb` is the human-readable action embedded in the error message (e.g. * `"remove container"` → `"failed to remove container: "`). */ diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index dc2ac7b869..c2e9e2ef15 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -105,6 +105,20 @@ export interface LegacyDbTomlValues { * `--experimental` declarative-schema-files branch of `legacyMigrateAndSeed`. */ readonly schemaPaths: ReadonlyArray; + /** + * `[db.migrations] schema_paths`, RAW patterns — the SAME env/remote-override + * resolution as {@link schemaPaths} above (`SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS`, + * remote-override tiering), but WITHOUT the `supabase/`-prefix + `path.Join`/`path.Clean` + * step (`config.go:976-979`) — Go's `utils.Config.Db.Migrations.SchemaPaths` pre-that- + * resolution form. `legacyPrepareShadowSource`'s `schemaPaths` input (`db diff`/`db pull`'s + * shadow-provisioning prelude) does that join itself (`legacyResolveSeedSqlPath`), so it + * needs THIS raw form — passing {@link schemaPaths} there would double-join a relative + * pattern (`supabase/supabase/...`). The `@supabase/config`-backed + * `context.config.db.migrations.schema_paths` these two callers used before is a DIFFERENT + * raw form: correct patterns, but never `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS`-overridden + * (`@supabase/config` has no viper-`AutomaticEnv` equivalent) — review: PRRT_kwDOErm0O86XDr4S. + */ + readonly schemaPathPatterns: ReadonlyArray; /** `[db.seed]` enabled + supabase-prefixed `sql_paths` globs — used by `down`. */ readonly seed: LegacyDbSeedTomlConfig; /** `[db.vault]` secrets (name → resolved value) — upserted by `up`/`down`. */ @@ -114,6 +128,17 @@ export interface LegacyDbTomlValues { * (Go's `Loading config override: [remotes.]` line), else `undefined`. */ readonly appliedRemote: string | undefined; + /** + * The config keys the matched remote block contributed at viper's OVERRIDE tier — see + * {@link LegacyRemoteOverride.remoteOverrideKeys}'s own doc comment for the full + * precedence rationale. Exposed here (in addition to being used internally, above) so a + * caller resolving a SEPARATE config read for the same linked ref — `legacyBuildLocalDbContainerInputs`, + * whose `@supabase/config`-backed loader merges the same remote block's VALUES but + * tracks none of which keys it set — can preserve the identical remote-over-env + * precedence for the shadow's own bootstrap fields (`db diff --linked`/`db pull`, + * CLI-1956), without re-deriving this set a third time. Empty when no remote matched. + */ + readonly remoteOverrideKeys: ReadonlySet; } /** `[db.seed]` config surfaced for `migration down`'s seed step. */ @@ -293,6 +318,11 @@ function legacyResolveValidatedRemoteProjectId( * `AutomaticEnv` — `config.go:635-637`), so the block value must beat the env override. */ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ + // The matched `[remotes.]` block's own `project_id` field is what selected it in the + // first place (`applyRemoteOverride` above matches on exactly this key), so it is ALWAYS + // present whenever a remote matched — same override-tier reasoning as every other key in + // this array, just guaranteed-present instead of block-dependent (review: PRRT_kwDOErm0O86XHGDL). + "project_id", "api.schemas", "db.port", "db.shadow_port", @@ -302,6 +332,14 @@ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ "db.seed.enabled", "db.seed.sql_paths", "auth.enabled", + // Not read by THIS reader's own resolved fields (nor by `apiUrl`'s own `api.port`/ + // `api.tls.enabled`/`api.external_url` inputs, unlike those three) — tracked purely because + // `legacyResolveLocalConfigValues`'s `legacyEnvOverrideBool("SUPABASE_API_ENABLED", ...)` + // call THROWS on a malformed override, which would abort resolution of the caller-needed + // fields it computes afterward (`apiPort`/`apiUrl`/`dbPort`/`rootKey`/etc.) — same + // "throws before a value the caller needs is resolved" rationale as `auth.enabled` above and + // `analytics.enabled`/`edge_runtime.deno_version` below (review: PRRT_kwDOErm0O86W5UlV). + "api.enabled", "edge_runtime.deno_version", "experimental.webhooks.enabled", "experimental.pgdelta.enabled", @@ -313,8 +351,263 @@ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ "analytics.gcp_project_id", "analytics.gcp_project_number", "analytics.gcp_jwt_path", + // Not read by THIS reader's own resolved fields — tracked so `remoteOverrideKeys` (exposed + // on this module's return value, see its own doc comment) also covers every field + // `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` + // (`legacy/shared/db-bootstrap/`) resolve for the shadow's own container spec on the + // `db diff --linked`/`db pull` native-provisioning path (CLI-1956). + "experimental.orioledb_version", + "experimental.s3_host", + "experimental.s3_region", + "experimental.s3_access_key", + "experimental.s3_secret_key", + "realtime.enabled", + "realtime.ip_version", + "realtime.max_header_length", + "storage.enabled", + "storage.file_size_limit", + "db.health_timeout", + "db.settings.effective_cache_size", + "db.settings.logical_decoding_work_mem", + "db.settings.maintenance_work_mem", + "db.settings.max_connections", + "db.settings.max_locks_per_transaction", + "db.settings.max_parallel_maintenance_workers", + "db.settings.max_parallel_workers", + "db.settings.max_parallel_workers_per_gather", + "db.settings.max_replication_slots", + "db.settings.max_slot_wal_keep_size", + "db.settings.max_standby_archive_delay", + "db.settings.max_standby_streaming_delay", + "db.settings.max_wal_size", + "db.settings.max_wal_senders", + "db.settings.max_worker_processes", + "db.settings.session_replication_role", + "db.settings.shared_buffers", + "db.settings.statement_timeout", + "db.settings.track_activity_query_size", + "db.settings.track_commit_timestamp", + "db.settings.wal_keep_size", + "db.settings.wal_sender_timeout", + "db.settings.work_mem", + "db.network_restrictions.enabled", + // Not read by `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` above — + // these feed `legacyResolveLocalConfigValues`'s OWN fields instead (`apiUrl`/`dbUrl`/ + // `dbPort`/`rootKey`/`jwtSecret`/`authSiteUrl`/`authJwtExpiry`/`anonKey`/`serviceRoleKey`), + // which the shadow's container spec/fresh-DB setup input also consume on the same + // `db diff --linked`/`db pull` path (review: PRRT_kwDOErm0O86W2tRi) — same override-tier + // gap as the block above, just for that resolver's reachable subset instead of this one's. + "db.root_key", + "api.port", + "api.tls.enabled", + // Not read by `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` above, + // same as `api.tls.enabled`/`api.port` — these feed `legacyResolveLocalConfigValues`'s own + // `readApiTlsFiles` gate (`apiEnabled && apiTlsEnabled`), which the shadow's own + // `db diff --linked`/`db pull` setup input also consumes on the same path. Without this, + // a matched remote's override-tier `api.tls.cert_path`/`key_path` could still lose to a + // stale/missing ambient `SUPABASE_API_TLS_CERT_PATH`/`SUPABASE_API_TLS_KEY_PATH` (review: + // PRRT_kwDOErm0O86W8ZYk). + "api.tls.cert_path", + "api.tls.key_path", + "api.external_url", + "auth.jwt_secret", + "auth.jwt_expiry", + "auth.site_url", + "auth.anon_key", + "auth.service_role_key", + // Not read by ANY of the resolvers above — these feed `legacyResolveLocalJwks`'s/ + // `legacyResolveAuthExternalUrl`'s/`legacyResolveConfiguredSigningKeys`'s own fields + // instead, which the shadow's PG15+ one-shot auth-migration job also consumes on the + // same `db diff --linked`/`db pull` path (review: PRRT_kwDOErm0O86W3Ox_) — same + // override-tier gap as the two blocks above, just for THOSE resolvers' reachable subset. + "auth.signing_keys_path", + "auth.external_url", + "auth.third_party.firebase.enabled", + "auth.third_party.firebase.project_id", + "auth.third_party.auth0.enabled", + "auth.third_party.auth0.tenant", + "auth.third_party.auth0.tenant_region", + "auth.third_party.aws_cognito.enabled", + "auth.third_party.aws_cognito.user_pool_id", + "auth.third_party.aws_cognito.user_pool_region", + "auth.third_party.clerk.enabled", + "auth.third_party.clerk.domain", + "auth.third_party.workos.enabled", + "auth.third_party.workos.issuer_url", + // Same "throws before a value the caller needs is resolved" bug class as `api.enabled`/ + // `auth.enabled`/`analytics.*`/`edge_runtime.deno_version` above, just for a much larger set of + // fields the doc comment on `legacyResolveLocalConfigValues`'s `remoteOverrideKeys` parameter + // used to claim were safe to leave ungated. That claim rested on "their own `legacyEnvOverride*` + // calls cannot throw before a value the caller needs has already been resolved" — which doesn't + // actually hold: `legacyResolveLocalConfigValues` is a single synchronous function that either + // returns its whole object or throws, so ANY unconditional throw anywhere in its body (not just + // ones textually positioned before a caller-needed field) aborts the entire call and denies the + // shadow every field, including the ones already computed as local variables earlier in the + // function. Every dotted key below resolves through `legacyEnvOverrideBool`/`legacyEnvOverrideUint`/ + // `legacyEnvOverrideAuthPasswordRequirements`, all of which throw on a malformed override — same + // as `api.enabled`'s own reasoning, just generalized (review: PRRT_kwDOErm0O86W6R-G). + "studio.enabled", + "studio.port", + "local_smtp.enabled", + "local_smtp.port", + "auth.enable_signup", + "auth.enable_anonymous_sign_ins", + "auth.enable_refresh_token_rotation", + "auth.refresh_token_reuse_interval", + "auth.enable_manual_linking", + "auth.minimum_password_length", + "auth.password_requirements", + "auth.passkey.enabled", + "auth.hook.mfa_verification_attempt.enabled", + "auth.hook.mfa_verification_attempt.uri", + "auth.hook.mfa_verification_attempt.secrets", + "auth.hook.password_verification_attempt.enabled", + "auth.hook.password_verification_attempt.uri", + "auth.hook.password_verification_attempt.secrets", + "auth.hook.custom_access_token.enabled", + "auth.hook.custom_access_token.uri", + "auth.hook.custom_access_token.secrets", + "auth.hook.send_sms.enabled", + "auth.hook.send_sms.uri", + "auth.hook.send_sms.secrets", + "auth.hook.send_email.enabled", + "auth.hook.send_email.uri", + "auth.hook.send_email.secrets", + "auth.hook.before_user_created.enabled", + "auth.hook.before_user_created.uri", + "auth.hook.before_user_created.secrets", + "auth.mfa.totp.enroll_enabled", + "auth.mfa.totp.verify_enabled", + "auth.mfa.phone.enroll_enabled", + "auth.mfa.phone.verify_enabled", + "auth.mfa.phone.otp_length", + "auth.mfa.web_authn.enroll_enabled", + "auth.mfa.web_authn.verify_enabled", + "auth.mfa.max_enrolled_factors", + "auth.captcha.enabled", + // `auth.captcha.provider` can't throw on its own (`legacyEnvOverride` is a plain string read), + // but `legacyValidateResolvedConfig`'s enum check (`legacy-config-validate.ts`, ported from + // `config.go:1099-1109`) rejects any value other than `hcaptcha`/`turnstile` — same + // "non-throwing read, throwing downstream consumer" class as `studio.api_url` below. A matched + // remote's own valid `provider` must beat a stale/unsupported ambient + // `SUPABASE_AUTH_CAPTCHA_PROVIDER`, or `legacyValidateResolvedConfig` aborts the whole + // synchronous `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a value Go's + // `v.Set` (override tier, above `AutomaticEnv`) never lets win. + "auth.captcha.provider", + // `auth.captcha.secret` is a `config.Secret` (`pkg/config/auth.go:292`), decrypted the same + // way `auth.email.smtp.pass` below is — `legacyResolveAuthCaptcha`'s ungated `legacyEnvOverride` + // call let a malformed ambient `SUPABASE_AUTH_CAPTCHA_SECRET` outrank a matched remote's own + // valid `secret` and throw during decryption, aborting the whole synchronous + // `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a value Go's `v.Set` + // (override tier, above `AutomaticEnv`) silently ignores — same bug class as `.pass` below + // (review: PRRT_kwDOErm0O86XJ4HR). + "auth.captcha.secret", + "auth.email.smtp.enabled", + "auth.email.smtp.port", + // `auth.email.smtp.pass` is a `config.Secret` (`pkg/config/auth.go:260`), decrypted uniformly + // by Go's `DecryptSecretHookFunc` decode hook regardless of which viper tier supplied the + // raw value — so when a matched remote block sets it, Go's `v.Set` (override tier) wins over + // `AutomaticEnv` and the decode hook decrypts the REMOTE's value; an ambient malformed + // `SUPABASE_AUTH_EMAIL_SMTP_PASS` never reaches decryption at all. `legacyResolveAuthEmailSmtp` + // previously ran `legacyEnvOverride` unconditionally before decrypting, so that same malformed + // env value could win over a matched remote's valid `pass` and throw, aborting the whole + // synchronous `legacyResolveLocalConfigValues` call — same bug class as `.enabled`/`.port` + // above, just for this Secret-typed leaf (review: PRRT_kwDOErm0O86XJYol). + "auth.email.smtp.pass", + // Not read by THIS reader's own resolved fields — tracked so `legacyResolveAuthEmail` + // (`legacy-local-config-values.ts`) also gates its own throw-capable + // `legacyEnvOverrideBool`/`legacyEnvOverrideUint` calls for these `auth.email.*` scalars, + // same "throws before a value the caller needs is resolved" bug class as + // `auth.email.smtp.enabled`/`.port` above (review: PRRT_kwDOErm0O86XHvYh). + "auth.email.enable_signup", + "auth.email.double_confirm_changes", + "auth.email.enable_confirmations", + "auth.email.secure_password_change", + "auth.email.otp_length", + "auth.email.otp_expiry", + "experimental.webhooks.enabled", + // `auth.sms.*` (`legacyResolveAuthSms`) has the identical "throws before a value the caller + // needs is resolved" bug class as every other group above: `enable_signup`/`enable_confirmations` + // and each provider's `enabled` run an UNGATED `legacyEnvOverrideBool`, and each provider's + // Secret-typed field (`auth_token`/`access_key`/`api_key`/`api_secret`, `pkg/config/auth.go: + // 339,345,351,358`) runs an UNGATED `legacyDecryptAuthSecret` — either can throw on a malformed + // ambient `SUPABASE_AUTH_SMS_*` override even when a matched remote block already set that field + // at viper's OVERRIDE tier, aborting the whole `legacyResolveLocalConfigValues` call (and the + // shadow it feeds via `legacyBuildLocalDbContainerInputs`) — reachable via `validateAuthSmsProviders`, + // called unconditionally whenever `authEnabled` (review: PRRT_kwDOErm0O86XFmjZ — the prior + // "unreachable from the shadow path" rejection missed this call site). + "auth.sms.enable_signup", + "auth.sms.enable_confirmations", + "auth.sms.twilio.enabled", + "auth.sms.twilio.auth_token", + "auth.sms.twilio_verify.enabled", + "auth.sms.twilio_verify.auth_token", + "auth.sms.messagebird.enabled", + "auth.sms.messagebird.access_key", + "auth.sms.textlocal.enabled", + "auth.sms.textlocal.api_key", + "auth.sms.vonage.enabled", + "auth.sms.vonage.api_secret", + // `auth.publishable_key`/`auth.secret_key` (`pkg/config/auth.go:181-182`) and + // `studio.openai_api_key` (`pkg/config/config.go:264`) are `config.Secret`-typed exactly like + // `auth.email.smtp.pass`/`auth.captcha.secret` above, decrypted via the same throw-capable + // `legacyDecryptAuthSecret` — but were never added to this allowlist when `anon_key`/ + // `service_role_key` (their sibling API-key pair, right next to them in + // `legacyResolveLocalConfigValues`'s return block) were gated. Same bug class: an ungated + // malformed ambient override can throw during decryption even when a matched remote block + // already set the field, aborting the whole call. + "auth.publishable_key", + "auth.secret_key", + "studio.openai_api_key", + // `studio.api_url` is validated with `legacyGoUrlParse` inside `legacyValidateResolvedConfig` + // (gated on `studio.enabled`, matching `studio.port` above) — a plain, non-throwing + // `legacyEnvOverride` read here can still flip that downstream validate() outcome, same + // "non-throwing read, throwing downstream consumer" class as the third_party required fields + // above. + "studio.api_url", ]; +/** + * `auth.external.` is a genuine map keyed by arbitrary provider name — not just the ~19 + * known ids `@supabase/config`'s schema recognizes, but any custom/unmodeled name a user's + * `[auth.external.]` table declares (`legacyResolveAuthExternalProviders`'s own doc + * comment). A fixed `LEGACY_ENV_OVERRIDABLE_KEYS` entry per provider can't cover every possible + * name a `[remotes.]` block might set, so these per-provider leaves are tracked dynamically + * in {@link applyRemoteOverride} instead (flattening whichever provider names the matched block + * actually supplies) rather than enumerated here. + */ +const LEGACY_AUTH_EXTERNAL_PROVIDER_FIELDS = [ + "enabled", + "client_id", + "secret", + "url", + "redirect_uri", + "skip_nonce_check", + "email_optional", +] as const; + +/** + * `auth.email.template.`/`auth.email.notification.` are the same shape of genuine, + * arbitrarily-keyed map as `auth.external.` above — a fixed `LEGACY_ENV_OVERRIDABLE_KEYS` + * entry per template/notification name can't cover every name a `[remotes.]` block might + * set, so these are also tracked dynamically in {@link applyRemoteOverride}. `content_path` is + * the field that can actually abort resolution (a matched remote's own valid path losing to a + * stale/missing ambient `_CONTENT_PATH` env var makes {@link legacyResolveAuthEmail}'s caller-side + * file read throw — same "non-throwing read, throwing downstream consumer" class as + * `auth.captcha.provider` above); `subject`/`content` can't throw the same way, but leaving them + * ungated is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ + * `redirect_uri` above (review: PRRT_kwDOErm0O86XLAYn, PRRT_kwDOErm0O86XLAYo). + */ +const LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS = ["subject", "content_path", "content"] as const; + +/** {@link LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS}'s notification-section sibling — same fields, plus `enabled`. */ +const LEGACY_AUTH_EMAIL_NOTIFICATION_FIELDS = [ + "enabled", + "subject", + "content_path", + "content", +] as const; + /** Whether `block` provides a value at the dotted `key` path (scalar, array, or sub-table). */ function legacyBlockProvidesKey(block: RawDoc, key: string): boolean { let current: unknown = block; @@ -351,6 +644,42 @@ function applyRemoteOverride( for (const key of LEGACY_ENV_OVERRIDABLE_KEYS) { if (legacyBlockProvidesKey(block, key)) remoteOverrideKeys.add(key); } + // `auth.external.` is a genuine map (arbitrary/custom provider names — see + // `LEGACY_AUTH_EXTERNAL_PROVIDER_FIELDS`'s own doc comment), so flatten whichever provider + // names/fields THIS matched block actually supplies instead of relying on a fixed list — + // same per-leaf override-tier semantics as `LEGACY_ENV_OVERRIDABLE_KEYS` above, just + // computed dynamically for this one dynamically-keyed section. + const externalBlock = asRecord(asRecord(block["auth"])?.["external"]); + if (externalBlock !== undefined) { + for (const providerName of Object.keys(externalBlock)) { + for (const field of LEGACY_AUTH_EXTERNAL_PROVIDER_FIELDS) { + const key = `auth.external.${providerName}.${field}`; + if (legacyBlockProvidesKey(block, key)) remoteOverrideKeys.add(key); + } + } + } + // `auth.email.template.`/`auth.email.notification.` are the same + // arbitrarily-keyed shape as `auth.external.` above — see + // `LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS`'s own doc comment. + const emailBlock = asRecord(block["auth"])?.["email"]; + const emailTemplateBlock = asRecord(asRecord(emailBlock)?.["template"]); + if (emailTemplateBlock !== undefined) { + for (const templateName of Object.keys(emailTemplateBlock)) { + for (const field of LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS) { + const key = `auth.email.template.${templateName}.${field}`; + if (legacyBlockProvidesKey(block, key)) remoteOverrideKeys.add(key); + } + } + } + const emailNotificationBlock = asRecord(asRecord(emailBlock)?.["notification"]); + if (emailNotificationBlock !== undefined) { + for (const notificationName of Object.keys(emailNotificationBlock)) { + for (const field of LEGACY_AUTH_EMAIL_NOTIFICATION_FIELDS) { + const key = `auth.email.notification.${notificationName}.${field}`; + if (legacyBlockProvidesKey(block, key)) remoteOverrideKeys.add(key); + } + } + } // `db.seed.enabled` is ALWAYS override-tier for a matched block: either the block set // it, or Go's `mergeRemoteConfig` forces it `false` when omitted (`config.go:638-640`) // — so env never overrides it on a matched-remote linked run. @@ -1123,7 +1452,16 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // `test db --local` joins `supabase_network_` while Go honors the // env id. This is independent of the linked-ref resolver, which reads the env var on // its own chain; the env value is bound regardless of whether a config file exists. - const projectIdEnv = envOverride("SUPABASE_PROJECT_ID"); + // UNLESS a matched `[remotes.]` block already set `project_id` at viper's override + // tier (`remoteOverrideKeys.has("project_id")`, always true whenever `appliedRemote` is + // set — see `LEGACY_ENV_OVERRIDABLE_KEYS`'s own doc comment on that key): that Set-tier + // value outranks `AutomaticEnv`, so a stale/differently-scoped `SUPABASE_PROJECT_ID` must + // not clobber it — otherwise a linked `db diff`/`db pull` mounts the wrong + // `supabase_edge_runtime_` Deno-cache volume for the matched remote (review: + // PRRT_kwDOErm0O86XHGDL). + const projectIdEnv = remoteOverrideKeys.has("project_id") + ? undefined + : envOverride("SUPABASE_PROJECT_ID"); if (projectIdEnv !== undefined) { projectId = nonEmptyString(legacyExpandEnv(projectIdEnv, lookup)); } @@ -2080,9 +2418,11 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }, migrationsEnabled, schemaPaths, + schemaPathPatterns, seed: { enabled: seedEnabled, sqlPaths: seedSqlPaths }, vault, appliedRemote, + remoteOverrideKeys, }; return values; }); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index a35e7048de..bc0baf1d6c 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -2871,4 +2871,163 @@ describe("legacyReadDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity) Effect.ensuring(restore(previous)), ); }); + + it.effect( + "prefers a matched [remotes.]'s project_id over a conflicting SUPABASE_PROJECT_ID", + () => { + // Regression (review: PRRT_kwDOErm0O86XHGDL) — Go's `mergeRemoteConfig` installs the + // matched block's OWN `project_id` at viper's override tier, above `AutomaticEnv` + // (`apps/cli-go/pkg/config/config.go:718-724`); that block is selected BECAUSE its + // `project_id` equals the resolved ref, so it must win even when an unrelated + // `SUPABASE_PROJECT_ID` is set to something else entirely. + const previous = process.env["SUPABASE_PROJECT_ID"]; + process.env["SUPABASE_PROJECT_ID"] = "local"; + const ref = "abcdefghijklmnopqrst"; + const dir = withConfig( + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBe("prod"); + expect(v.remoteOverrideKeys.has("project_id")).toBe(true); + expect(Option.getOrNull(v.projectId)).toBe(ref); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.ensuring(restore(previous)), + ); + }, + ); + + it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { + const previous = process.env["SUPABASE_PROJECT_ID"]; + process.env["SUPABASE_PROJECT_ID"] = "env-project"; + const ref = "abcdefghijklmnopqrst"; + const dir = withConfig(['project_id = "toml-project"', ""].join("\n")); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBeUndefined(); + expect(Option.getOrNull(v.projectId)).toBe("env-project"); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.ensuring(restore(previous)), + ); + }); +}); + +describe("legacyReadDbToml remoteOverrideKeys — auth.captcha.provider / auth.email.template/notification", () => { + const ref = "abcdefghijklmnopqrst"; + + it.effect("tracks auth.captcha.provider when a matched remote block supplies it", () => { + // Regression (review: PRRT_kwDOErm0O86XLAYn) — `provider` is a plain string leaf, not one of + // `applyRemoteOverride`'s dynamically-keyed sections, so it must be tracked via + // `LEGACY_ENV_OVERRIDABLE_KEYS` like any other fixed-name field. + const dir = withConfig( + [ + "[auth.captcha]", + 'provider = "hcaptcha"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.auth.captcha]", + 'provider = "turnstile"', + "", + ].join("\n"), + ); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBe("prod"); + expect(v.remoteOverrideKeys.has("auth.captcha.provider")).toBe(true); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("tracks a matched remote block's auth.email.template. leaves dynamically", () => { + // Regression (review: PRRT_kwDOErm0O86XLAYn) — `auth.email.template..*` is a + // genuinely arbitrarily-keyed map, same shape as `auth.external..*`, so it must be + // flattened dynamically instead of relying on a fixed `LEGACY_ENV_OVERRIDABLE_KEYS` entry. + const dir = withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.auth.email.template.invite]", + 'content_path = "remote-invite.html"', + "", + ].join("\n"), + ); + // Template `content_path` resolves relative to the project root (`workdir`, i.e. `dir`). + writeFileSync(join(dir, "remote-invite.html"), ""); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBe("prod"); + expect(v.remoteOverrideKeys.has("auth.email.template.invite.content_path")).toBe(true); + expect(v.remoteOverrideKeys.has("auth.email.template.invite.subject")).toBe(false); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect( + "tracks a matched remote block's auth.email.notification. leaves dynamically", + () => { + // Regression (review: PRRT_kwDOErm0O86XLAYo) — `auth.email.notification..*`'s + // sibling case, including `enabled` (a direct `legacyEnvOverrideBool` throw site). + const dir = withConfig( + [ + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.auth.email.notification.password_changed]", + "enabled = true", + 'content_path = "remote-pw-changed.html"', + "", + ].join("\n"), + ); + // Notification `content_path` resolves relative to the `supabase/` dir. + writeFileSync(join(dir, "supabase", "remote-pw-changed.html"), ""); + return readRef(dir, ref).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.appliedRemote).toBe("prod"); + expect( + v.remoteOverrideKeys.has("auth.email.notification.password_changed.enabled"), + ).toBe(true); + expect( + v.remoteOverrideKeys.has("auth.email.notification.password_changed.content_path"), + ).toBe(true); + expect( + v.remoteOverrideKeys.has("auth.email.notification.password_changed.subject"), + ).toBe(false); + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-push-core.ts b/apps/cli/src/legacy/shared/legacy-db-push-core.ts index be8759fda7..3bcbcf4058 100644 --- a/apps/cli/src/legacy/shared/legacy-db-push-core.ts +++ b/apps/cli/src/legacy/shared/legacy-db-push-core.ts @@ -339,6 +339,7 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush cwd: workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, + projectEnv: toml.projectEnv, }; yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { enabled: cacheEnabled, diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 1a145feceb..0292e08a7a 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -126,6 +126,26 @@ export const LEGACY_CLI_PROJECT_LABEL = "com.supabase.cli.project"; */ export const LEGACY_CLI_WORKDIR_LABEL = "com.supabase.cli.workdir"; +/** + * TS-port-only Docker label (no Go equivalent, same reasoning as {@link + * LEGACY_CLI_WORKDIR_LABEL}) recording a randomized fallback identifier for a container + * created WITHOUT a name (`container-lifecycle.ts`'s `legacyCreateContainer` fallback path + * — today, only the `db diff`/`db pull` shadow database, see + * `db-bootstrap/shadow-database.ts`'s `legacyCreateShadowDatabase`). Originally introduced + * so an orphaned shadow (this process killed before its own finalizer, + * `legacyRemoveShadowDatabase`, ever runs) could still have its staged host secret + * directory reclaimed by `legacyCleanupStartSecrets` (`legacy-start-secrets-cleanup.ts`, + * which prefers this label's value over `container.name` whenever it's present) — Docker's + * own auto-generated name bears no relation to the randomized directory id, so without this + * label there'd be no way to recover it (review: PRRT_kwDOErm0O86W8ZYt). Secrets are now + * delivered via `docker cp` (never staged on host disk at all, for named or unnamed + * containers alike — see `legacyCreateShadowDatabase`'s own doc comment), so that original + * reclaim purpose no longer applies; kept as a general-purpose way for orphan cleanup to + * recognize an unnamed container at all, read back by {@link legacyListContainerIdsAndNames} + * (`legacy-docker-lifecycle.ts`). + */ +export const LEGACY_CLI_SECRET_DIR_LABEL = "com.supabase.cli.secret-dir"; + /** * Go's `utils.GetDockerIds()` (`apps/cli-go/internal/utils/config.go:82-98`) — the * 13 service container ids (excludes `db`, `network`, and the `differ` shadow diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index d76b01a168..e85e071a90 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -2,7 +2,7 @@ import { Data, Effect, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { legacyDescribeContainerCliFailure, spawnContainerCli } from "./legacy-container-cli.ts"; -import { LEGACY_CLI_WORKDIR_LABEL } from "./legacy-docker-ids.ts"; +import { LEGACY_CLI_SECRET_DIR_LABEL, LEGACY_CLI_WORKDIR_LABEL } from "./legacy-docker-ids.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -131,34 +131,43 @@ export const legacyListContainersByLabel = ( }); /** - * A single `docker ps` result row's id, name, and staging workdir together. + * A single `docker ps` result row's id, name, staging workdir, and (unnamed containers + * only) staged secret-dir id together. * * `workdir` is `LEGACY_CLI_WORKDIR_LABEL`'s value read straight off the container (see * that constant's doc comment) — empty when the container carries no such label, which * `legacyCleanupStartSecrets` treats as "fall back to the caller's own workdir" (a * container `start` created before this label existed, or created by a Go binary). + * + * `secretDirId` is `LEGACY_CLI_SECRET_DIR_LABEL`'s value (see that constant's own doc + * comment) — empty for every NAMED container (which never carries this label; its secret + * directory is just its own name) and non-empty only for an unnamed container that staged + * secrets under a randomized id (today, only the `db diff`/`db pull` shadow database). + * `legacyCleanupStartSecrets` prefers this over `name` when present, since `name` for such + * a container is Docker's own auto-generated string, which bears no relation to the + * directory that was actually staged. */ export interface LegacyContainerIdName { readonly id: string; readonly name: string; readonly workdir: string; + readonly secretDirId: string; } /** * Combined-format sibling of {@link legacyListContainersByLabel}: fetches a - * container's id, name, AND staging workdir from a SINGLE `docker ps --format - * "{{.ID}}\t{{.Names}}\t{{.Label \"com.supabase.cli.workdir\"}}"` invocation, - * rather than one call per field. Go's SDK-based `Docker.ContainerList` gets - * all of this (and every other field) from the one Engine API response it - * already makes; two separately-`--format`ted CLI calls here would silently - * double the real Docker request count relative to Go even though each call's - * own output is individually correct — exactly the bug the cli-e2e-ci - * request-log parity harness caught for `stop` (an extra `GET /containers/json` - * versus Go's single call). Used by {@link legacyDockerRemoveAll}, which needs - * ids to stop containers, for callers (`stop`, `start`'s rollback) that ALSO - * need names and workdirs for {@link legacyCleanupStartSecrets} — see that - * function's doc comment and {@link legacyDockerRemoveAll}'s - * `onContainersRemoved` parameter. + * container's id, name, staging workdir, AND secret-dir id from a SINGLE `docker ps + * --format "{{.ID}}\t{{.Names}}\t{{.Label \"com.supabase.cli.workdir\"}}\t{{.Label + * \"com.supabase.cli.secret-dir\"}}"` invocation, rather than one call per field. Go's + * SDK-based `Docker.ContainerList` gets all of this (and every other field) from the one + * Engine API response it already makes; separately-`--format`ted CLI calls here would + * silently multiply the real Docker request count relative to Go even though each call's + * own output is individually correct — exactly the bug the cli-e2e-ci request-log parity + * harness caught for `stop` (an extra `GET /containers/json` versus Go's single call). + * Used by {@link legacyDockerRemoveAll}, which needs ids to stop containers, for callers + * (`stop`, `start`'s rollback) that ALSO need names, workdirs, and secret-dir ids for + * {@link legacyCleanupStartSecrets} — see that function's doc comment and + * {@link legacyDockerRemoveAll}'s `onContainersRemoved` parameter. */ export const legacyListContainerIdsAndNames = ( spawner: Spawner, @@ -170,12 +179,12 @@ export const legacyListContainerIdsAndNames = ( spawnDockerPsLines(spawner, { projectIdFilter: opts.projectIdFilter, all: opts.all, - formatArg: `{{.ID}}\t{{.Names}}\t{{.Label "${LEGACY_CLI_WORKDIR_LABEL}"}}`, + formatArg: `{{.ID}}\t{{.Names}}\t{{.Label "${LEGACY_CLI_WORKDIR_LABEL}"}}\t{{.Label "${LEGACY_CLI_SECRET_DIR_LABEL}"}}`, }).pipe( Effect.map((lines) => lines.map((line) => { - const [id = "", name = "", workdir = ""] = line.split("\t"); - return { id, name, workdir }; + const [id = "", name = "", workdir = "", secretDirId = ""] = line.split("\t"); + return { id, name, workdir, secretDirId }; }), ), ); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts index 03a802f197..d099fd6e81 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.unit.test.ts @@ -166,36 +166,49 @@ describe("legacyListContainersByLabel", () => { }); describe("legacyListContainerIdsAndNames", () => { - it.live("parses id, name, and the com.supabase.cli.workdir label from a single ps call", () => { - const mock = mockSpawner({ - stdout: - "abc123\tsupabase_kong_demo\t/home/user/demo\ndef456\tsupabase_db_demo\t/home/user/demo\n", - }); - return legacyListContainerIdsAndNames(mock.spawner, { - projectIdFilter: "com.supabase.cli.project=demo", - all: true, - }).pipe( - Effect.map((containers) => { - expect(containers).toEqual([ - { id: "abc123", name: "supabase_kong_demo", workdir: "/home/user/demo" }, - { id: "def456", name: "supabase_db_demo", workdir: "/home/user/demo" }, - ]); - expect(mock.spawned).toEqual([ - { - command: "docker", - args: [ - "ps", - "--filter", - "label=com.supabase.cli.project=demo", - "--all", - "--format", - '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', - ], - }, - ]); - }), - ); - }); + it.live( + "parses id, name, and the com.supabase.cli.workdir/secret-dir labels from a single ps call", + () => { + const mock = mockSpawner({ + stdout: + "abc123\tsupabase_kong_demo\t/home/user/demo\t\ndef456\tsupabase_db_demo\t/home/user/demo\t\n", + }); + return legacyListContainerIdsAndNames(mock.spawner, { + projectIdFilter: "com.supabase.cli.project=demo", + all: true, + }).pipe( + Effect.map((containers) => { + expect(containers).toEqual([ + { + id: "abc123", + name: "supabase_kong_demo", + workdir: "/home/user/demo", + secretDirId: "", + }, + { + id: "def456", + name: "supabase_db_demo", + workdir: "/home/user/demo", + secretDirId: "", + }, + ]); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: [ + "ps", + "--filter", + "label=com.supabase.cli.project=demo", + "--all", + "--format", + '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}\t{{.Label "com.supabase.cli.secret-dir"}}', + ], + }, + ]); + }), + ); + }, + ); it.live( "resolves an empty workdir for a container carrying no com.supabase.cli.workdir label", @@ -204,13 +217,44 @@ describe("legacyListContainerIdsAndNames", () => { // label — a container `start` created before this label existed, or one a Go binary // created. `legacyCleanupStartSecrets` treats this empty string as "fall back to the // caller's own workdir" (see that function's doc comment). - const mock = mockSpawner({ stdout: "abc123\tsupabase_kong_demo\t\n" }); + const mock = mockSpawner({ stdout: "abc123\tsupabase_kong_demo\t\t\n" }); + return legacyListContainerIdsAndNames(mock.spawner, { + projectIdFilter: "com.supabase.cli.project=demo", + all: true, + }).pipe( + Effect.map((containers) => { + expect(containers).toEqual([ + { id: "abc123", name: "supabase_kong_demo", workdir: "", secretDirId: "" }, + ]); + }), + ); + }, + ); + + it.live( + "parses a non-empty com.supabase.cli.secret-dir label for an unnamed shadow container", + () => { + // The shadow database is created with no name (Docker auto-generates one) and stages its + // secrets under a randomized `shadow-` id it stamps onto this label (see + // `LEGACY_CLI_SECRET_DIR_LABEL`'s own doc comment) precisely so a later orphan-reaping + // `stop` can still find it (review: PRRT_kwDOErm0O86W8ZYt). + const mock = mockSpawner({ + stdout: + "abc123\tsad_turing\t/home/user/demo\tshadow-11111111-1111-1111-1111-111111111111\n", + }); return legacyListContainerIdsAndNames(mock.spawner, { projectIdFilter: "com.supabase.cli.project=demo", all: true, }).pipe( Effect.map((containers) => { - expect(containers).toEqual([{ id: "abc123", name: "supabase_kong_demo", workdir: "" }]); + expect(containers).toEqual([ + { + id: "abc123", + name: "sad_turing", + workdir: "/home/user/demo", + secretDirId: "shadow-11111111-1111-1111-1111-111111111111", + }, + ]); }), ); }, diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index d62df19e18..16becff5a9 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -360,13 +360,25 @@ export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { * analytics.ts:31-39`) already guards the `config.toml`-sourced value at * decode time, so this is belt-and-suspenders for that source and the sole * guard for the env-override one, which bypasses that schema entirely. + * + * `skipEnvOverride` (default `false`) is `legacyResolveLocalConfigValues`'s `remoteWins + * ("analytics.backend")` — `analytics.backend` is in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`), so a matched remote block's value must win over a + * conflicting `SUPABASE_ANALYTICS_BACKEND` the same way every other gated field in that function + * does (review: PRRT_kwDOErm0O86W30n6). Threaded as a parameter (rather than gating at the call + * site with a bare ternary) so the single validation check below still narrows `configured` + * itself to the return type on the remote-wins path — `ProjectConfig["analytics"]["backend"]`'s + * declared type is a plain `string`, not the literal union, so a call-site ternary would + * re-widen the result. */ function envOverrideAnalyticsBackend( configured: string, projectEnvValues: Readonly> | undefined, + skipEnvOverride = false, ): "postgres" | "bigquery" { - const value = - legacyEnvOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured; + const value = skipEnvOverride + ? configured + : (legacyEnvOverride("SUPABASE_ANALYTICS_BACKEND", undefined, projectEnvValues) ?? configured); if (value !== "postgres" && value !== "bigquery") { throw new LegacyInvalidAnalyticsBackendEnvOverrideError("analytics.backend", value); } @@ -585,28 +597,47 @@ function legacyDecryptAuthSecret( export function legacyResolveAuthEmailSmtp( authDocument: Readonly> | undefined, projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as {@link legacyResolveConfiguredSigningKeys}'s own + * parameter — `auth.email.smtp.enabled`/`.port`/`.pass` are in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`) because their ungated `legacyEnvOverrideBool`/ + * `legacyEnvOverridePort`/`legacyEnvOverride` calls below THROW (directly, or via + * `legacyDecryptAuthSecret` for `.pass`) on a malformed override even when a matched remote + * block already set them, which would abort the whole caller (`legacyResolveLocalConfigValues`, + * and the shadow it feeds) on an env value Go silently ignores. Defaults to empty for + * `start.handler.ts`'s callers, which never resolve a `[remotes.]` block for this read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): (LegacySmtpInput & { readonly senderName: string | undefined }) | undefined { const smtpDoc = asRecord(asRecord(authDocument?.["email"])?.["smtp"]); if (smtpDoc === undefined) return undefined; return { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", - smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, - "auth.email.smtp.enabled", - projectEnvValues, - ), + enabled: remoteOverrideKeys.has("auth.email.smtp.enabled") + ? smtpDoc["enabled"] === undefined + ? true + : smtpDoc["enabled"] === true + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", + smtpDoc["enabled"] === undefined ? true : smtpDoc["enabled"] === true, + "auth.email.smtp.enabled", + projectEnvValues, + ), host: legacyEnvOverride( "SUPABASE_AUTH_EMAIL_SMTP_HOST", typeof smtpDoc["host"] === "string" ? smtpDoc["host"] : "", projectEnvValues, ) ?? "", - port: legacyEnvOverridePort( - "SUPABASE_AUTH_EMAIL_SMTP_PORT", - typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, - "auth.email.smtp.port", - projectEnvValues, - ), + port: remoteOverrideKeys.has("auth.email.smtp.port") + ? typeof smtpDoc["port"] === "number" + ? smtpDoc["port"] + : 0 + : legacyEnvOverridePort( + "SUPABASE_AUTH_EMAIL_SMTP_PORT", + typeof smtpDoc["port"] === "number" ? smtpDoc["port"] : 0, + "auth.email.smtp.port", + projectEnvValues, + ), user: legacyEnvOverride( "SUPABASE_AUTH_EMAIL_SMTP_USER", @@ -616,16 +647,24 @@ export function legacyResolveAuthEmailSmtp( // Go's `Auth.Email.Smtp.Pass` is a `config.Secret` (`pkg/config/auth.go:260`), // decrypted by `DecryptSecretHookFunc` at decode time for both the TOML // value and any env override — same treatment as `jwt_secret`/the API - // keys below, via the same `legacyDecryptAuthSecret` helper. - pass: - legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_AUTH_EMAIL_SMTP_PASS", + // keys below, via the same `legacyDecryptAuthSecret` helper. Same remote-over-env + // precedence as `.enabled`/`.port` above — `auth.email.smtp.pass` is now in + // `LEGACY_ENV_OVERRIDABLE_KEYS` because an ungated `legacyEnvOverride` call here let a + // malformed ambient `SUPABASE_AUTH_EMAIL_SMTP_PASS` outrank a matched remote's own valid + // `pass` and throw during decryption, aborting the whole caller (review: PRRT_kwDOErm0O86XJYol). + pass: remoteOverrideKeys.has("auth.email.smtp.pass") + ? (legacyDecryptAuthSecret( typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", projectEnvValues, - ) ?? "", - projectEnvValues, - ) ?? "", + ) ?? "") + : (legacyDecryptAuthSecret( + legacyEnvOverride( + "SUPABASE_AUTH_EMAIL_SMTP_PASS", + typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", + projectEnvValues, + ) ?? "", + projectEnvValues, + ) ?? ""), adminEmail: legacyEnvOverride( "SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", @@ -667,12 +706,28 @@ export function legacyResolveAuthCaptcha( authDocument: Readonly> | undefined, captcha: ProjectConfig["auth"]["captcha"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as {@link legacyResolveConfiguredSigningKeys}'s own + * parameter — `auth.captcha.enabled`/`.secret` are in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`) because their ungated `legacyEnvOverrideBool`/ + * `legacyEnvOverride` calls below THROW (directly, or via `legacyDecryptAuthSecret` for + * `.secret`) on a malformed override even when a matched remote block already set them, which + * would abort the whole caller (`legacyResolveLocalConfigValues`, and the shadow it feeds) on + * an env value Go silently ignores. `auth.captcha.provider` can't throw the same way + * (`legacyEnvOverride` is a plain string read), but `legacyValidateResolvedConfig`'s enum check + * downstream rejects anything other than `hcaptcha`/`turnstile` — same "non-throwing read, + * throwing downstream consumer" class as `studio.api_url` (review: PRRT_kwDOErm0O86XLAYn). + * Defaults to empty for `start.handler.ts`'s callers, which never resolve a `[remotes.]` + * block for this config read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): LegacyCaptchaInput | undefined { const captchaDoc = asRecord(authDocument?.["captcha"]); return captcha ? { - enabled: - captchaDoc !== undefined + enabled: remoteOverrideKeys.has("auth.captcha.enabled") + ? (captcha.enabled ?? false) + : captchaDoc !== undefined ? legacyEnvOverrideBool( "SUPABASE_AUTH_CAPTCHA_ENABLED", captcha.enabled ?? false, @@ -680,18 +735,28 @@ export function legacyResolveAuthCaptcha( projectEnvValues, ) : (captcha.enabled ?? false), - provider: - captchaDoc !== undefined + provider: remoteOverrideKeys.has("auth.captcha.provider") + ? captcha.provider + : captchaDoc !== undefined ? legacyEnvOverride( "SUPABASE_AUTH_CAPTCHA_PROVIDER", captcha.provider, projectEnvValues, ) : captcha.provider, + // Go's `Auth.Captcha.Secret` is a `config.Secret` (`pkg/config/auth.go:292`), decrypted + // by `DecryptSecretHookFunc` at decode time — same treatment as `auth.email.smtp.pass` + // above. Same remote-over-env precedence as `.enabled` above — `auth.captcha.secret` is + // in `LEGACY_ENV_OVERRIDABLE_KEYS` because an ungated `legacyEnvOverride` call here let a + // malformed ambient `SUPABASE_AUTH_CAPTCHA_SECRET` outrank a matched remote's own valid + // `secret` and throw during decryption, aborting the whole caller + // (review: PRRT_kwDOErm0O86XJ4HR). secret: legacyDecryptAuthSecret( - captchaDoc !== undefined - ? legacyEnvOverride("SUPABASE_AUTH_CAPTCHA_SECRET", captcha.secret, projectEnvValues) - : captcha.secret, + remoteOverrideKeys.has("auth.captcha.secret") + ? captcha.secret + : captchaDoc !== undefined + ? legacyEnvOverride("SUPABASE_AUTH_CAPTCHA_SECRET", captcha.secret, projectEnvValues) + : captcha.secret, projectEnvValues, ), } @@ -817,23 +882,42 @@ function loadSigningKeys(workdir: string, signingKeysPath: string): ReadonlyArra * utils.Config.Auth.SigningKeys`) so the two resolvers can never disagree on * which signing key(s) apply — a prerequisite for GoTrue-issued tokens to * verify against the published JWKS at all. + * + * `remoteOverrideKeys` (default empty, so `supabase start`/`legacyResolveLocalConfigValues`'s + * OTHER callers see exactly the same behavior as before): `auth.signing_keys_path` set at + * viper's OVERRIDE tier by a matched remote block must win over a conflicting + * `SUPABASE_AUTH_SIGNING_KEYS_PATH` — this resolver's caller `legacyResolveLocalJwks` feeds the + * shadow's PG15+ one-shot auth-migration job on the `db diff --linked`/`db pull` path (CLI-1956, + * review: PRRT_kwDOErm0O86W3Ox_), and `legacyResolveLocalConfigValues`'s own `signingKey` (used + * to sign `anonKey`/`serviceRoleKey`, already remote-gated fields) reaches the same shadow. + * `auth.enabled` itself needs the identical gate: it's in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`), and an ungated `legacyEnvOverrideBool` call THROWS on a + * malformed `SUPABASE_AUTH_ENABLED` even when a matched remote block already set `auth.enabled` + * at viper's OVERRIDE tier — a value Go's `Validate` never even evaluates the env var for in + * that case — which would otherwise abort this whole resolver (and the shadow it feeds) on an + * env value Go silently ignores (review: PRRT_kwDOErm0O86W30n6). */ export function legacyResolveConfiguredSigningKeys( config: ProjectConfig, workdir: string, projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): ReadonlyArray | undefined { - const authEnabled = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const signingKeysPath = legacyEnvOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); + const authEnabled = remoteOverrideKeys.has("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + const signingKeysPath = remoteOverrideKeys.has("auth.signing_keys_path") + ? config.auth.signing_keys_path + : legacyEnvOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); return authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 ? loadSigningKeys(workdir, signingKeysPath) : undefined; @@ -948,6 +1032,23 @@ export function legacyResolveAuthEmail( email: ProjectConfig["auth"]["email"], authDocument: Record | undefined, projectEnvValues: Readonly> | undefined, + // `remoteOverrideKeys` (default empty, so `start.handler.ts`/`db/start/start.handler.ts` — + // which never resolve a matched `[remotes.*]` block — see identical behavior to before this + // parameter existed): a matched remote's override-tier `auth.email.*` leaf must win over a + // conflicting `SUPABASE_AUTH_EMAIL_*` env var the same way every other gated field in this + // file does, and — same "throws before a value the caller needs is resolved" bug class as + // `auth.enabled`/`api.enabled` — an ungated malformed override here aborts the WHOLE + // `legacyResolveLocalConfigValues` call for the `db diff --linked`/`db pull` shadow-provisioning + // path (CLI-1956), denying the shadow every field, not just this one (review: PRRT_kwDOErm0O86XHvYh). + // Per-entry `template..*`/`notification..*` leaves (dynamically keyed, tracked via + // `LEGACY_AUTH_EMAIL_TEMPLATE_FIELDS`/`LEGACY_AUTH_EMAIL_NOTIFICATION_FIELDS`, same shape as + // `auth.external..*`) need the identical gating: `content_path` is the field that can + // actually abort resolution (a stale/missing ambient `_CONTENT_PATH` env var wins over a + // matched remote's own valid path and makes the caller-side file read below throw); + // `subject`/`content`/notification's `enabled` can't throw the same way, but leaving them + // ungated is still a precedence bug, same reasoning as `auth.external.*`'s non-throwing fields + // (review: PRRT_kwDOErm0O86XLAYn, PRRT_kwDOErm0O86XLAYo). + remoteOverrideKeys: ReadonlySet = new Set(), ): LegacyResolvedAuthEmail { const emailDoc = asRecord(authDocument?.["email"]); const templateDoc = asRecord(emailDoc?.["template"]); @@ -956,13 +1057,16 @@ export function legacyResolveAuthEmail( const template: Record = {}; for (const [name, tmpl] of Object.entries(email.template)) { const envPrefix = `SUPABASE_AUTH_EMAIL_TEMPLATE_${name.toUpperCase()}`; - const envSubject = legacyEnvOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); const rawSubjectPresent = asRecord(templateDoc?.[name])?.["subject"] !== undefined; + const envSubject = remoteOverrideKeys.has(`auth.email.template.${name}.subject`) + ? undefined + : legacyEnvOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); template[name] = { subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), - content_path: - legacyEnvOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? - tmpl.content_path, + content_path: remoteOverrideKeys.has(`auth.email.template.${name}.content_path`) + ? tmpl.content_path + : (legacyEnvOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? + tmpl.content_path), // Go's `Content *string` is folded from `${envPrefix}_CONTENT` by the same generic // Viper/`AutomaticEnv` bind as every other field (`config.go:749`, before `Config.Validate` // at `config.go:882`) — so an env override makes `content` "present" here exactly like a raw @@ -970,77 +1074,98 @@ export function legacyResolveAuthEmail( // unless `content_path` is also set. content_present: asRecord(templateDoc?.[name])?.["content"] !== undefined || - legacyEnvOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined, + (remoteOverrideKeys.has(`auth.email.template.${name}.content`) + ? false + : legacyEnvOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), }; } const notification: Record = {}; for (const [name, tmpl] of Object.entries(email.notification)) { const envPrefix = `SUPABASE_AUTH_EMAIL_NOTIFICATION_${name.toUpperCase()}`; - const envSubject = legacyEnvOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); const rawSubjectPresent = asRecord(notificationDoc?.[name])?.["subject"] !== undefined; + const envSubject = remoteOverrideKeys.has(`auth.email.notification.${name}.subject`) + ? undefined + : legacyEnvOverride(`${envPrefix}_SUBJECT`, undefined, projectEnvValues); notification[name] = { - enabled: legacyEnvOverrideBool( - `${envPrefix}_ENABLED`, - tmpl.enabled, - `auth.email.notification.${name}.enabled`, - projectEnvValues, - ), + enabled: remoteOverrideKeys.has(`auth.email.notification.${name}.enabled`) + ? tmpl.enabled + : legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + tmpl.enabled, + `auth.email.notification.${name}.enabled`, + projectEnvValues, + ), subject: envSubject ?? (rawSubjectPresent ? tmpl.subject : undefined), - content_path: - legacyEnvOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? - tmpl.content_path, + content_path: remoteOverrideKeys.has(`auth.email.notification.${name}.content_path`) + ? tmpl.content_path + : (legacyEnvOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? + tmpl.content_path), // Same `_CONTENT` env-presence fold as the template loop above. content_present: asRecord(notificationDoc?.[name])?.["content"] !== undefined || - legacyEnvOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined, + (remoteOverrideKeys.has(`auth.email.notification.${name}.content`) + ? false + : legacyEnvOverride(`${envPrefix}_CONTENT`, undefined, projectEnvValues) !== undefined), }; } return { ...email, - enable_signup: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", - email.enable_signup, - "auth.email.enable_signup", - projectEnvValues, - ), - double_confirm_changes: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_DOUBLE_CONFIRM_CHANGES", - email.double_confirm_changes, - "auth.email.double_confirm_changes", - projectEnvValues, - ), - enable_confirmations: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_ENABLE_CONFIRMATIONS", - email.enable_confirmations, - "auth.email.enable_confirmations", - projectEnvValues, - ), - secure_password_change: legacyEnvOverrideBool( - "SUPABASE_AUTH_EMAIL_SECURE_PASSWORD_CHANGE", - email.secure_password_change, - "auth.email.secure_password_change", - projectEnvValues, - ), + enable_signup: remoteOverrideKeys.has("auth.email.enable_signup") + ? email.enable_signup + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", + email.enable_signup, + "auth.email.enable_signup", + projectEnvValues, + ), + double_confirm_changes: remoteOverrideKeys.has("auth.email.double_confirm_changes") + ? email.double_confirm_changes + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_DOUBLE_CONFIRM_CHANGES", + email.double_confirm_changes, + "auth.email.double_confirm_changes", + projectEnvValues, + ), + enable_confirmations: remoteOverrideKeys.has("auth.email.enable_confirmations") + ? email.enable_confirmations + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_ENABLE_CONFIRMATIONS", + email.enable_confirmations, + "auth.email.enable_confirmations", + projectEnvValues, + ), + secure_password_change: remoteOverrideKeys.has("auth.email.secure_password_change") + ? email.secure_password_change + : legacyEnvOverrideBool( + "SUPABASE_AUTH_EMAIL_SECURE_PASSWORD_CHANGE", + email.secure_password_change, + "auth.email.secure_password_change", + projectEnvValues, + ), max_frequency: legacyEnvOverride( "SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", email.max_frequency, projectEnvValues, ) ?? email.max_frequency, - otp_length: legacyEnvOverrideUint( - "SUPABASE_AUTH_EMAIL_OTP_LENGTH", - "auth.email.otp_length", - email.otp_length, - projectEnvValues, - ), - otp_expiry: legacyEnvOverrideUint( - "SUPABASE_AUTH_EMAIL_OTP_EXPIRY", - "auth.email.otp_expiry", - email.otp_expiry, - projectEnvValues, - ), + otp_length: remoteOverrideKeys.has("auth.email.otp_length") + ? email.otp_length + : legacyEnvOverrideUint( + "SUPABASE_AUTH_EMAIL_OTP_LENGTH", + "auth.email.otp_length", + email.otp_length, + projectEnvValues, + ), + otp_expiry: remoteOverrideKeys.has("auth.email.otp_expiry") + ? email.otp_expiry + : legacyEnvOverrideUint( + "SUPABASE_AUTH_EMAIL_OTP_EXPIRY", + "auth.email.otp_expiry", + email.otp_expiry, + projectEnvValues, + ), template, notification, }; @@ -1318,135 +1443,188 @@ function legacyEnvOverrideSessionReplicationRole( * serialize — mirroring the `db.port`/`db.major_version`-style fix already * applied at this same `start` call site, just fanned out across every * `[db.settings]` field instead of one. + * + * `remoteOverrideKeys` (default empty, so `db start`/`db reset` — which never resolve a + * `[remotes.]` block for this config read — see exactly the same behavior as + * before): the `db.settings.*` keys a matched remote block set at viper's OVERRIDE tier + * (`v.Set`, above `AutomaticEnv`, `apps/cli-go/pkg/config/config.go:635-640`) — a remote + * value for, say, `max_connections` must beat a conflicting `SUPABASE_DB_SETTINGS_MAX_ + * CONNECTIONS`, exactly like `legacy-db-config.toml-read.ts`'s own `db.major_version` + * gate. `db diff --linked`/`db pull` (CLI-1956) pass the set their sibling `legacyReadDbToml` + * call already computed, via `legacyBuildLocalDbContainerInputs`. */ export function legacyResolveDbSettingsEnvOverrides( settings: ProjectConfig["db"]["settings"], projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): NonNullable { + const remoteWins = (dottedFieldPath: string): boolean => remoteOverrideKeys.has(dottedFieldPath); return { - effective_cache_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", - settings?.effective_cache_size, - projectEnvValues, - ), - logical_decoding_work_mem: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", - settings?.logical_decoding_work_mem, - projectEnvValues, - ), - maintenance_work_mem: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", - settings?.maintenance_work_mem, - projectEnvValues, - ), - max_connections: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", - "db.settings.max_connections", - settings?.max_connections, - projectEnvValues, - ), - max_locks_per_transaction: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", - "db.settings.max_locks_per_transaction", - settings?.max_locks_per_transaction, - projectEnvValues, - ), - max_parallel_maintenance_workers: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", - "db.settings.max_parallel_maintenance_workers", - settings?.max_parallel_maintenance_workers, - projectEnvValues, - ), - max_parallel_workers: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", - "db.settings.max_parallel_workers", - settings?.max_parallel_workers, - projectEnvValues, - ), - max_parallel_workers_per_gather: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", - "db.settings.max_parallel_workers_per_gather", - settings?.max_parallel_workers_per_gather, - projectEnvValues, - ), - max_replication_slots: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", - "db.settings.max_replication_slots", - settings?.max_replication_slots, - projectEnvValues, - ), - max_slot_wal_keep_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", - settings?.max_slot_wal_keep_size, - projectEnvValues, - ), - max_standby_archive_delay: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", - settings?.max_standby_archive_delay, - projectEnvValues, - ), - max_standby_streaming_delay: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", - settings?.max_standby_streaming_delay, - projectEnvValues, - ), - max_wal_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", - settings?.max_wal_size, - projectEnvValues, - ), - max_wal_senders: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", - "db.settings.max_wal_senders", - settings?.max_wal_senders, - projectEnvValues, - ), - max_worker_processes: envOverrideOptionalUint( - "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", - "db.settings.max_worker_processes", - settings?.max_worker_processes, - projectEnvValues, - ), - session_replication_role: legacyEnvOverrideSessionReplicationRole( - settings?.session_replication_role, - projectEnvValues, - ), - shared_buffers: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", - settings?.shared_buffers, - projectEnvValues, - ), - statement_timeout: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", - settings?.statement_timeout, - projectEnvValues, - ), - track_activity_query_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", - settings?.track_activity_query_size, - projectEnvValues, - ), - track_commit_timestamp: legacyEnvOverrideOptionalBool( - "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", - settings?.track_commit_timestamp, - "db.settings.track_commit_timestamp", - projectEnvValues, - ), - wal_keep_size: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", - settings?.wal_keep_size, - projectEnvValues, - ), - wal_sender_timeout: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", - settings?.wal_sender_timeout, - projectEnvValues, - ), - work_mem: legacyEnvOverride( - "SUPABASE_DB_SETTINGS_WORK_MEM", - settings?.work_mem, - projectEnvValues, - ), + effective_cache_size: remoteWins("db.settings.effective_cache_size") + ? settings?.effective_cache_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", + settings?.effective_cache_size, + projectEnvValues, + ), + logical_decoding_work_mem: remoteWins("db.settings.logical_decoding_work_mem") + ? settings?.logical_decoding_work_mem + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", + settings?.logical_decoding_work_mem, + projectEnvValues, + ), + maintenance_work_mem: remoteWins("db.settings.maintenance_work_mem") + ? settings?.maintenance_work_mem + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", + settings?.maintenance_work_mem, + projectEnvValues, + ), + max_connections: remoteWins("db.settings.max_connections") + ? settings?.max_connections + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", + "db.settings.max_connections", + settings?.max_connections, + projectEnvValues, + ), + max_locks_per_transaction: remoteWins("db.settings.max_locks_per_transaction") + ? settings?.max_locks_per_transaction + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", + "db.settings.max_locks_per_transaction", + settings?.max_locks_per_transaction, + projectEnvValues, + ), + max_parallel_maintenance_workers: remoteWins("db.settings.max_parallel_maintenance_workers") + ? settings?.max_parallel_maintenance_workers + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", + "db.settings.max_parallel_maintenance_workers", + settings?.max_parallel_maintenance_workers, + projectEnvValues, + ), + max_parallel_workers: remoteWins("db.settings.max_parallel_workers") + ? settings?.max_parallel_workers + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", + "db.settings.max_parallel_workers", + settings?.max_parallel_workers, + projectEnvValues, + ), + max_parallel_workers_per_gather: remoteWins("db.settings.max_parallel_workers_per_gather") + ? settings?.max_parallel_workers_per_gather + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", + "db.settings.max_parallel_workers_per_gather", + settings?.max_parallel_workers_per_gather, + projectEnvValues, + ), + max_replication_slots: remoteWins("db.settings.max_replication_slots") + ? settings?.max_replication_slots + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", + "db.settings.max_replication_slots", + settings?.max_replication_slots, + projectEnvValues, + ), + max_slot_wal_keep_size: remoteWins("db.settings.max_slot_wal_keep_size") + ? settings?.max_slot_wal_keep_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", + settings?.max_slot_wal_keep_size, + projectEnvValues, + ), + max_standby_archive_delay: remoteWins("db.settings.max_standby_archive_delay") + ? settings?.max_standby_archive_delay + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", + settings?.max_standby_archive_delay, + projectEnvValues, + ), + max_standby_streaming_delay: remoteWins("db.settings.max_standby_streaming_delay") + ? settings?.max_standby_streaming_delay + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", + settings?.max_standby_streaming_delay, + projectEnvValues, + ), + max_wal_size: remoteWins("db.settings.max_wal_size") + ? settings?.max_wal_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", + settings?.max_wal_size, + projectEnvValues, + ), + max_wal_senders: remoteWins("db.settings.max_wal_senders") + ? settings?.max_wal_senders + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", + "db.settings.max_wal_senders", + settings?.max_wal_senders, + projectEnvValues, + ), + max_worker_processes: remoteWins("db.settings.max_worker_processes") + ? settings?.max_worker_processes + : envOverrideOptionalUint( + "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", + "db.settings.max_worker_processes", + settings?.max_worker_processes, + projectEnvValues, + ), + session_replication_role: remoteWins("db.settings.session_replication_role") + ? settings?.session_replication_role + : legacyEnvOverrideSessionReplicationRole( + settings?.session_replication_role, + projectEnvValues, + ), + shared_buffers: remoteWins("db.settings.shared_buffers") + ? settings?.shared_buffers + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", + settings?.shared_buffers, + projectEnvValues, + ), + statement_timeout: remoteWins("db.settings.statement_timeout") + ? settings?.statement_timeout + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", + settings?.statement_timeout, + projectEnvValues, + ), + track_activity_query_size: remoteWins("db.settings.track_activity_query_size") + ? settings?.track_activity_query_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", + settings?.track_activity_query_size, + projectEnvValues, + ), + track_commit_timestamp: remoteWins("db.settings.track_commit_timestamp") + ? settings?.track_commit_timestamp + : legacyEnvOverrideOptionalBool( + "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", + settings?.track_commit_timestamp, + "db.settings.track_commit_timestamp", + projectEnvValues, + ), + wal_keep_size: remoteWins("db.settings.wal_keep_size") + ? settings?.wal_keep_size + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", + settings?.wal_keep_size, + projectEnvValues, + ), + wal_sender_timeout: remoteWins("db.settings.wal_sender_timeout") + ? settings?.wal_sender_timeout + : legacyEnvOverride( + "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", + settings?.wal_sender_timeout, + projectEnvValues, + ), + work_mem: remoteWins("db.settings.work_mem") + ? settings?.work_mem + : legacyEnvOverride("SUPABASE_DB_SETTINGS_WORK_MEM", settings?.work_mem, projectEnvValues), }; } @@ -1501,15 +1679,28 @@ function asRecord(value: unknown): Record | undefined { * this single standalone helper instead of independent per-caller derivations. Hoisted here (was * private to `start/start.handler.ts`) once `db/start/start.handler.ts`'s own native container * bootstrap became a third caller — see `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + * + * `remoteOverrideKeys` (default empty, so `db start`/`supabase start` — which never resolve a + * `[remotes.]` block for this config read — see exactly the same behavior as before): + * `auth.external_url` set at viper's OVERRIDE tier by a matched remote block + * (`apps/cli-go/pkg/config/config.go:635-640`) must win over a conflicting + * `SUPABASE_AUTH_EXTERNAL_URL`, matching the `db.root_key`/`auth.jwt_secret`-style gates already + * applied elsewhere in this file — `db diff --linked`/`db pull` (CLI-1956) pass the set their + * sibling `legacyReadDbToml` call already computed, via `legacyBuildLocalDbContainerInputs` + * (review: PRRT_kwDOErm0O86W3Ox_). */ export function legacyResolveAuthExternalUrl( document: Readonly> | undefined, projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): string | undefined { const rawAuthExternalUrl = asRecord(document?.["auth"])?.["external_url"]; + const configuredAuthExternalUrl = + typeof rawAuthExternalUrl === "string" ? rawAuthExternalUrl : undefined; + if (remoteOverrideKeys.has("auth.external_url")) return configuredAuthExternalUrl; return legacyEnvOverride( "SUPABASE_AUTH_EXTERNAL_URL", - typeof rawAuthExternalUrl === "string" ? rawAuthExternalUrl : undefined, + configuredAuthExternalUrl, projectEnvValues, ); } @@ -1573,6 +1764,23 @@ export function legacyResolveAuthHooks( authDocument: Readonly> | undefined, hook: ProjectConfig["auth"]["hook"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as {@link legacyResolveConfiguredSigningKeys}'s own + * parameter — every `auth.hook..{enabled,uri,secrets}` leaf is in + * `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) because Go's + * `mergeRemoteConfig` flattens the WHOLE matched block via `u.AllKeys()` and applies every + * leaf — not just `enabled` — with `v.Set` (override tier, above `AutomaticEnv`, + * `apps/cli-go/pkg/config/config.go:718-724`). `enabled`'s ungated `legacyEnvOverrideBool` + * call additionally THROWS on a malformed override even when a matched remote block already + * set it, which would abort the whole caller (`legacyResolveLocalConfigValues`, and the shadow + * it feeds) on an env value Go silently ignores. `uri`/`secrets` can't throw the same way + * (plain `legacyEnvOverride`), but leaving them ungated is still a precedence bug: a remote's + * valid `uri` must beat a stale/malformed `SUPABASE_AUTH_HOOK__URI`, otherwise + * `legacyValidateResolvedConfig`'s scheme check can reject a linked diff/pull that Go would + * accept (review: PRRT_kwDOErm0O86XGTq5). Defaults to empty for `start.handler.ts`'s callers, + * which never resolve a `[remotes.]` block for this config read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): LegacyResolvedAuthHooks { const hookDocument = asRecord(authDocument?.["hook"]); const result = {} as Record; @@ -1580,22 +1788,28 @@ export function legacyResolveAuthHooks( const h = hook[hookType]; const hookSectionPresent = asRecord(hookDocument?.[hookType]) !== undefined; const envPrefix = `SUPABASE_AUTH_HOOK_${hookType.toUpperCase()}`; - const enabled = hookSectionPresent - ? legacyEnvOverrideBool( - `${envPrefix}_ENABLED`, - h.enabled, - `auth.hook.${hookType}.enabled`, - projectEnvValues, - ) - : h.enabled; + const enabled = remoteOverrideKeys.has(`auth.hook.${hookType}.enabled`) + ? h.enabled + : hookSectionPresent + ? legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + h.enabled, + `auth.hook.${hookType}.enabled`, + projectEnvValues, + ) + : h.enabled; const uri = - (hookSectionPresent - ? legacyEnvOverride(`${envPrefix}_URI`, h.uri, projectEnvValues) - : h.uri) ?? ""; + (remoteOverrideKeys.has(`auth.hook.${hookType}.uri`) + ? h.uri + : hookSectionPresent + ? legacyEnvOverride(`${envPrefix}_URI`, h.uri, projectEnvValues) + : h.uri) ?? ""; const secrets = - (hookSectionPresent - ? legacyEnvOverride(`${envPrefix}_SECRETS`, h.secrets, projectEnvValues) - : h.secrets) ?? ""; + (remoteOverrideKeys.has(`auth.hook.${hookType}.secrets`) + ? h.secrets + : hookSectionPresent + ? legacyEnvOverride(`${envPrefix}_SECRETS`, h.secrets, projectEnvValues) + : h.secrets) ?? ""; result[LEGACY_HOOK_TYPE_TO_CAMEL[hookType]] = { enabled, uri, secrets }; } return result as LegacyResolvedAuthHooks; @@ -1619,41 +1833,63 @@ export function legacyResolveAuthHooks( export function legacyResolveAuthMfa( mfa: ProjectConfig["auth"]["mfa"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as {@link legacyResolveConfiguredSigningKeys}'s own + * parameter — every throw-capable `auth.mfa.*` leaf below (`enroll_enabled`/`verify_enabled` + * per factor, `phone.otp_length`, `max_enrolled_factors`) is in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`) because its ungated `legacyEnvOverrideBool`/ + * `legacyEnvOverrideUint` call THROWS on a malformed override even when a matched remote block + * already set it, which would abort the whole caller (`legacyResolveLocalConfigValues`, and the + * shadow it feeds) on an env value Go silently ignores. `template`/`max_frequency` stay + * ungated: plain `legacyEnvOverride` string reads never throw. Defaults to empty for + * `start.handler.ts`'s callers, which never resolve a `[remotes.]` block for this read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): ProjectConfig["auth"]["mfa"] { return { totp: { - enroll_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", - mfa.totp.enroll_enabled, - "auth.mfa.totp.enroll_enabled", - projectEnvValues, - ), - verify_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", - mfa.totp.verify_enabled, - "auth.mfa.totp.verify_enabled", - projectEnvValues, - ), + enroll_enabled: remoteOverrideKeys.has("auth.mfa.totp.enroll_enabled") + ? mfa.totp.enroll_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", + mfa.totp.enroll_enabled, + "auth.mfa.totp.enroll_enabled", + projectEnvValues, + ), + verify_enabled: remoteOverrideKeys.has("auth.mfa.totp.verify_enabled") + ? mfa.totp.verify_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", + mfa.totp.verify_enabled, + "auth.mfa.totp.verify_enabled", + projectEnvValues, + ), }, phone: { - enroll_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_PHONE_ENROLL_ENABLED", - mfa.phone.enroll_enabled, - "auth.mfa.phone.enroll_enabled", - projectEnvValues, - ), - verify_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_PHONE_VERIFY_ENABLED", - mfa.phone.verify_enabled, - "auth.mfa.phone.verify_enabled", - projectEnvValues, - ), - otp_length: legacyEnvOverrideUint( - "SUPABASE_AUTH_MFA_PHONE_OTP_LENGTH", - "auth.mfa.phone.otp_length", - mfa.phone.otp_length, - projectEnvValues, - ), + enroll_enabled: remoteOverrideKeys.has("auth.mfa.phone.enroll_enabled") + ? mfa.phone.enroll_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_PHONE_ENROLL_ENABLED", + mfa.phone.enroll_enabled, + "auth.mfa.phone.enroll_enabled", + projectEnvValues, + ), + verify_enabled: remoteOverrideKeys.has("auth.mfa.phone.verify_enabled") + ? mfa.phone.verify_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_PHONE_VERIFY_ENABLED", + mfa.phone.verify_enabled, + "auth.mfa.phone.verify_enabled", + projectEnvValues, + ), + otp_length: remoteOverrideKeys.has("auth.mfa.phone.otp_length") + ? mfa.phone.otp_length + : legacyEnvOverrideUint( + "SUPABASE_AUTH_MFA_PHONE_OTP_LENGTH", + "auth.mfa.phone.otp_length", + mfa.phone.otp_length, + projectEnvValues, + ), template: legacyEnvOverride( "SUPABASE_AUTH_MFA_PHONE_TEMPLATE", @@ -1668,25 +1904,31 @@ export function legacyResolveAuthMfa( ) ?? mfa.phone.max_frequency, }, web_authn: { - enroll_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", - mfa.web_authn.enroll_enabled, - "auth.mfa.web_authn.enroll_enabled", - projectEnvValues, - ), - verify_enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_MFA_WEB_AUTHN_VERIFY_ENABLED", - mfa.web_authn.verify_enabled, - "auth.mfa.web_authn.verify_enabled", - projectEnvValues, - ), + enroll_enabled: remoteOverrideKeys.has("auth.mfa.web_authn.enroll_enabled") + ? mfa.web_authn.enroll_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_WEB_AUTHN_ENROLL_ENABLED", + mfa.web_authn.enroll_enabled, + "auth.mfa.web_authn.enroll_enabled", + projectEnvValues, + ), + verify_enabled: remoteOverrideKeys.has("auth.mfa.web_authn.verify_enabled") + ? mfa.web_authn.verify_enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_MFA_WEB_AUTHN_VERIFY_ENABLED", + mfa.web_authn.verify_enabled, + "auth.mfa.web_authn.verify_enabled", + projectEnvValues, + ), }, - max_enrolled_factors: legacyEnvOverrideUint( - "SUPABASE_AUTH_MFA_MAX_ENROLLED_FACTORS", - "auth.mfa.max_enrolled_factors", - mfa.max_enrolled_factors, - projectEnvValues, - ), + max_enrolled_factors: remoteOverrideKeys.has("auth.mfa.max_enrolled_factors") + ? mfa.max_enrolled_factors + : legacyEnvOverrideUint( + "SUPABASE_AUTH_MFA_MAX_ENROLLED_FACTORS", + "auth.mfa.max_enrolled_factors", + mfa.max_enrolled_factors, + projectEnvValues, + ), }; } @@ -1967,105 +2209,137 @@ export function legacyResolveGotrueOAuthServer( * need the same eager `auth.third_party.*` resolution to reproduce Go's unconditional * `Config.Load` decode, per `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" (review: * PRRT_kwDOErm0O86WXFqj). + * + * `remoteOverrideKeys` (default empty, so neither existing caller's behavior changes): each + * `auth.third_party..*` field is in `LEGACY_ENV_OVERRIDABLE_KEYS` + * (`legacy-db-config.toml-read.ts`) and `legacyEnvOverrideBool` THROWS on a malformed override, + * so an ungated call here would abort this whole function (and the shadow it feeds via + * `legacyBuildLocalDbContainerInputs`) on a malformed `SUPABASE_AUTH_THIRD_PARTY_*_ENABLED` even + * when a matched remote block already set that provider's field at viper's OVERRIDE tier — same + * `auth.enabled` bug class (review: PRRT_kwDOErm0O86W30n6). */ export function legacyResolveThirdPartyProviders( thirdParty: ProjectConfig["auth"]["third_party"], projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): ReadonlyArray { + const remoteWins = (dottedFieldPath: string): boolean => remoteOverrideKeys.has(dottedFieldPath); const resolved: Array = []; if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - thirdParty.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.firebase.enabled") + ? thirdParty.firebase.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + thirdParty.firebase.enabled, + "auth.third_party.firebase.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "firebase", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - thirdParty.firebase.project_id, - projectEnvValues, - ) ?? "", + (remoteWins("auth.third_party.firebase.project_id") + ? thirdParty.firebase.project_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", + thirdParty.firebase.project_id, + projectEnvValues, + )) ?? "", }); } if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - thirdParty.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.auth0.enabled") + ? thirdParty.auth0.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", + thirdParty.auth0.enabled, + "auth.third_party.auth0.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "auth0", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - thirdParty.auth0.tenant, - projectEnvValues, - ) ?? "", + (remoteWins("auth.third_party.auth0.tenant") + ? thirdParty.auth0.tenant + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", + thirdParty.auth0.tenant, + projectEnvValues, + )) ?? "", }); } if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - thirdParty.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.aws_cognito.enabled") + ? thirdParty.aws_cognito.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", + thirdParty.aws_cognito.enabled, + "auth.third_party.aws_cognito.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "cognito", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - thirdParty.aws_cognito.user_pool_id, - projectEnvValues, - ) ?? "", - cognitoUserPoolRegion: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - thirdParty.aws_cognito.user_pool_region, - projectEnvValues, - ), + (remoteWins("auth.third_party.aws_cognito.user_pool_id") + ? thirdParty.aws_cognito.user_pool_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", + thirdParty.aws_cognito.user_pool_id, + projectEnvValues, + )) ?? "", + cognitoUserPoolRegion: remoteWins("auth.third_party.aws_cognito.user_pool_region") + ? thirdParty.aws_cognito.user_pool_region + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", + thirdParty.aws_cognito.user_pool_region, + projectEnvValues, + ), }); } if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - thirdParty.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.clerk.enabled") + ? thirdParty.clerk.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + thirdParty.clerk.enabled, + "auth.third_party.clerk.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "clerk", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - thirdParty.clerk.domain, - projectEnvValues, - ) ?? "", + (remoteWins("auth.third_party.clerk.domain") + ? thirdParty.clerk.domain + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + thirdParty.clerk.domain, + projectEnvValues, + )) ?? "", }); } if ( - legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - thirdParty.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ) + remoteWins("auth.third_party.workos.enabled") + ? thirdParty.workos.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + thirdParty.workos.enabled, + "auth.third_party.workos.enabled", + projectEnvValues, + ) ) { resolved.push({ provider: "workos", requiredField: - legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - thirdParty.workos.issuer_url, - projectEnvValues, - ) ?? "", + (remoteWins("auth.third_party.workos.issuer_url") + ? thirdParty.workos.issuer_url + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + thirdParty.workos.issuer_url, + projectEnvValues, + )) ?? "", }); } return resolved; @@ -2122,8 +2396,25 @@ export function legacyResolveAuthSms( authDocument: Readonly> | undefined, sms: ProjectConfig["auth"]["sms"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as every other gated resolver in this file. Reachable from + * the `db diff --linked`/`db pull` shadow path via `validateAuthSmsProviders`, called + * unconditionally from `legacyResolveLocalConfigValues` whenever `authEnabled` — a prior review + * (PRRT_kwDOErm0O86XFmjZ) rejected this gap as "unreachable," having only grepped direct + * `legacyResolveAuthSms(` call sites in `start.handler.ts`/`db/start/start.handler.ts` and + * missed this file's own `validateAuthSmsProviders` wrapper. `enable_signup`/ + * `enable_confirmations`/each provider's `enabled` THROW via `legacyEnvOverrideBool`, and each + * provider's Secret-typed field (`auth_token`/`access_key`/`api_key`/`api_secret`, + * `pkg/config/auth.go:339,345,351,358`) THROWS via `legacyDecryptAuthSecret` — either can abort + * this whole call (and the shadow it feeds) on a malformed ambient `SUPABASE_AUTH_SMS_*` + * override even when a matched remote block already set that field at viper's OVERRIDE tier. + * Defaults to empty for `start.handler.ts`/`db/start/start.handler.ts`'s callers, which never + * resolve a `[remotes.]` block for this config read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): ProjectConfig["auth"]["sms"] { const smsDoc = asRecord(authDocument?.["sms"]); + const remoteWins = (dottedFieldPath: string): boolean => remoteOverrideKeys.has(dottedFieldPath); function providerPresent(providerName: (typeof LEGACY_SMS_PROVIDER_ORDER)[number]): boolean { // `twilio` is always considered present — see this function's doc comment. @@ -2135,6 +2426,7 @@ export function legacyResolveAuthSms( providerName: (typeof LEGACY_SMS_PROVIDER_ORDER)[number], configured: boolean, ): boolean { + if (remoteWins(`auth.sms.${providerName}.enabled`)) return configured; if (!providerPresent(providerName)) return configured; return legacyEnvOverrideBool( `SUPABASE_AUTH_SMS_${providerName.toUpperCase()}_ENABLED`, @@ -2157,6 +2449,17 @@ export function legacyResolveAuthSms( ); } + /** Resolves a provider's Secret-typed field, gated the same way `auth.email.smtp.pass` is. */ + function resolveSecretField( + providerName: (typeof LEGACY_SMS_PROVIDER_ORDER)[number], + field: string, + configured: string | undefined, + ): string | undefined { + return remoteWins(`auth.sms.${providerName}.${field}`) + ? legacyDecryptAuthSecret(configured, projectEnvValues) + : legacyDecryptAuthSecret(resolveField(providerName, field, configured), projectEnvValues); + } + const twilioEnabled = resolveEnabled("twilio", sms.twilio.enabled); const twilioVerifyEnabled = resolveEnabled("twilio_verify", sms.twilio_verify.enabled); const messagebirdEnabled = resolveEnabled("messagebird", sms.messagebird.enabled); @@ -2164,12 +2467,14 @@ export function legacyResolveAuthSms( const vonageEnabled = resolveEnabled("vonage", sms.vonage.enabled); const anyProviderEnabled = twilioEnabled || twilioVerifyEnabled || messagebirdEnabled || textlocalEnabled || vonageEnabled; - const enableSignupConfigured = legacyEnvOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", - sms.enable_signup, - "auth.sms.enable_signup", - projectEnvValues, - ); + const enableSignupConfigured = remoteWins("auth.sms.enable_signup") + ? sms.enable_signup + : legacyEnvOverrideBool( + "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", + sms.enable_signup, + "auth.sms.enable_signup", + projectEnvValues, + ); return { ...sms, @@ -2178,12 +2483,14 @@ export function legacyResolveAuthSms( // `EnableSignup = false` before `buildGotrueEnv` ever reads it, so phone signup is never // enabled with no provider configured to actually deliver an OTP. enable_signup: anyProviderEnabled ? enableSignupConfigured : false, - enable_confirmations: legacyEnvOverrideBool( - "SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", - sms.enable_confirmations, - "auth.sms.enable_confirmations", - projectEnvValues, - ), + enable_confirmations: remoteWins("auth.sms.enable_confirmations") + ? sms.enable_confirmations + : legacyEnvOverrideBool( + "SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", + sms.enable_confirmations, + "auth.sms.enable_confirmations", + projectEnvValues, + ), template: legacyEnvOverride("SUPABASE_AUTH_SMS_TEMPLATE", sms.template, projectEnvValues) ?? sms.template, @@ -2195,10 +2502,7 @@ export function legacyResolveAuthSms( account_sid: resolveField("twilio", "account_sid", sms.twilio.account_sid) ?? "", message_service_sid: resolveField("twilio", "message_service_sid", sms.twilio.message_service_sid) ?? "", - auth_token: legacyDecryptAuthSecret( - resolveField("twilio", "auth_token", sms.twilio.auth_token), - projectEnvValues, - ), + auth_token: resolveSecretField("twilio", "auth_token", sms.twilio.auth_token), }, twilio_verify: { enabled: twilioVerifyEnabled, @@ -2208,35 +2512,23 @@ export function legacyResolveAuthSms( "message_service_sid", sms.twilio_verify.message_service_sid, ), - auth_token: legacyDecryptAuthSecret( - resolveField("twilio_verify", "auth_token", sms.twilio_verify.auth_token), - projectEnvValues, - ), + auth_token: resolveSecretField("twilio_verify", "auth_token", sms.twilio_verify.auth_token), }, messagebird: { enabled: messagebirdEnabled, originator: resolveField("messagebird", "originator", sms.messagebird.originator), - access_key: legacyDecryptAuthSecret( - resolveField("messagebird", "access_key", sms.messagebird.access_key), - projectEnvValues, - ), + access_key: resolveSecretField("messagebird", "access_key", sms.messagebird.access_key), }, textlocal: { enabled: textlocalEnabled, sender: resolveField("textlocal", "sender", sms.textlocal.sender), - api_key: legacyDecryptAuthSecret( - resolveField("textlocal", "api_key", sms.textlocal.api_key), - projectEnvValues, - ), + api_key: resolveSecretField("textlocal", "api_key", sms.textlocal.api_key), }, vonage: { enabled: vonageEnabled, from: resolveField("vonage", "from", sms.vonage.from), api_key: resolveField("vonage", "api_key", sms.vonage.api_key), - api_secret: legacyDecryptAuthSecret( - resolveField("vonage", "api_secret", sms.vonage.api_secret), - projectEnvValues, - ), + api_secret: resolveSecretField("vonage", "api_secret", sms.vonage.api_secret), }, }; } @@ -2252,8 +2544,9 @@ function validateAuthSmsProviders( authDocument: Record | undefined, sms: ProjectConfig["auth"]["sms"], projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): void { - const resolved = legacyResolveAuthSms(authDocument, sms, projectEnvValues); + const resolved = legacyResolveAuthSms(authDocument, sms, projectEnvValues, remoteOverrideKeys); function requireField(provider: string, field: string, value: string | undefined): void { if (value === undefined || value.length === 0) { @@ -2407,6 +2700,23 @@ export function legacyResolveAuthExternalProviders( authDocument: Readonly> | undefined, external: ProjectConfig["auth"]["external"], projectEnvValues: Readonly> | undefined, + /** + * Same remote-over-env precedence as every other gated resolver in this file — + * `auth.external..*` leaves are tracked dynamically in `applyRemoteOverride` + * (`legacy-db-config.toml-read.ts`), not via a fixed `LEGACY_ENV_OVERRIDABLE_KEYS` entry, + * since provider names are an arbitrary/custom-keyed map (see this function's own doc comment + * above). `enabled`/`skip_nonce_check`/`email_optional` THROW via `legacyEnvOverrideBool` and + * `secret` THROWS via `legacyDecryptAuthSecret` (`Secret`-typed, `pkg/config/auth.go:364`) on a + * malformed override even when a matched remote block already set that field, which would abort + * the whole caller (`legacyResolveLocalConfigValues`, and the shadow it feeds) on a value Go's + * `v.Set` (override tier) silently ignores; `client_id`/`url`/`redirect_uri` can't throw the + * same way, but leaving them ungated is still a precedence bug — a remote's valid value must + * beat a stale `SUPABASE_AUTH_EXTERNAL__*` env var, same reasoning as + * `legacyResolveAuthHooks`'s `uri`/`secrets` (review: PRRT_kwDOErm0O86XKYiF). Defaults to empty + * for `start.handler.ts`/`db/start/start.handler.ts`'s callers, which never resolve a + * `[remotes.]` block for this config read. + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): Record { const externalDoc = asRecord(authDocument?.["external"]); @@ -2458,36 +2768,48 @@ export function legacyResolveAuthExternalProviders( ); result[name] = { - enabled: legacyEnvOverrideBool( - `${envPrefix}_ENABLED`, - configuredEnabled, - `auth.external.${name}.enabled`, - projectEnvValues, - ), + enabled: remoteOverrideKeys.has(`auth.external.${name}.enabled`) + ? configuredEnabled + : legacyEnvOverrideBool( + `${envPrefix}_ENABLED`, + configuredEnabled, + `auth.external.${name}.enabled`, + projectEnvValues, + ), clientId: - legacyEnvOverride(`${envPrefix}_CLIENT_ID`, configuredClientId, projectEnvValues) ?? "", - secret: legacyDecryptAuthSecret( - legacyEnvOverride(`${envPrefix}_SECRET`, configuredSecret, projectEnvValues), - projectEnvValues, - ), - url: legacyEnvOverride(`${envPrefix}_URL`, configuredUrl, projectEnvValues) ?? "", - redirectUri: legacyEnvOverride( - `${envPrefix}_REDIRECT_URI`, - configuredRedirectUri, - projectEnvValues, - ), - skipNonceCheck: legacyEnvOverrideBool( - `${envPrefix}_SKIP_NONCE_CHECK`, - configuredSkipNonceCheck, - `auth.external.${name}.skip_nonce_check`, - projectEnvValues, - ), - emailOptional: legacyEnvOverrideBool( - `${envPrefix}_EMAIL_OPTIONAL`, - configuredEmailOptional, - `auth.external.${name}.email_optional`, - projectEnvValues, - ), + (remoteOverrideKeys.has(`auth.external.${name}.client_id`) + ? configuredClientId + : legacyEnvOverride(`${envPrefix}_CLIENT_ID`, configuredClientId, projectEnvValues)) ?? + "", + secret: remoteOverrideKeys.has(`auth.external.${name}.secret`) + ? legacyDecryptAuthSecret(configuredSecret, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride(`${envPrefix}_SECRET`, configuredSecret, projectEnvValues), + projectEnvValues, + ), + url: + (remoteOverrideKeys.has(`auth.external.${name}.url`) + ? configuredUrl + : legacyEnvOverride(`${envPrefix}_URL`, configuredUrl, projectEnvValues)) ?? "", + redirectUri: remoteOverrideKeys.has(`auth.external.${name}.redirect_uri`) + ? configuredRedirectUri + : legacyEnvOverride(`${envPrefix}_REDIRECT_URI`, configuredRedirectUri, projectEnvValues), + skipNonceCheck: remoteOverrideKeys.has(`auth.external.${name}.skip_nonce_check`) + ? configuredSkipNonceCheck + : legacyEnvOverrideBool( + `${envPrefix}_SKIP_NONCE_CHECK`, + configuredSkipNonceCheck, + `auth.external.${name}.skip_nonce_check`, + projectEnvValues, + ), + emailOptional: remoteOverrideKeys.has(`auth.external.${name}.email_optional`) + ? configuredEmailOptional + : legacyEnvOverrideBool( + `${envPrefix}_EMAIL_OPTIONAL`, + configuredEmailOptional, + `auth.external.${name}.email_optional`, + projectEnvValues, + ), }; } return result; @@ -2524,11 +2846,17 @@ function validateAuthExternalProviders( authDocument: Record | undefined, external: ProjectConfig["auth"]["external"], projectEnvValues: Readonly> | undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): void { // Derived from `legacyResolveAuthExternalProviders`'s unfiltered result so this validation // path and `start.handler.ts`'s GoTrue env builder can't drift — same precedent as // `legacyResolveAuthHooks`'s validation caller above. - const resolved = legacyResolveAuthExternalProviders(authDocument, external, projectEnvValues); + const resolved = legacyResolveAuthExternalProviders( + authDocument, + external, + projectEnvValues, + remoteOverrideKeys, + ); for (const [name, provider] of Object.entries(resolved)) { if (!provider.enabled) continue; if (provider.clientId.length === 0) { @@ -2610,7 +2938,74 @@ export function legacyResolveLocalConfigValues( * guessed at. */ document: Readonly> | undefined = undefined, + /** + * Config keys a matched `[remotes.]` block contributed at viper's OVERRIDE tier (Go's + * `v.Set`, applied ABOVE `AutomaticEnv` — `apps/cli-go/pkg/config/config.go:635-640`) — see + * `legacy-db-config.toml-read.ts`'s `LegacyRemoteOverride.remoteOverrideKeys` doc comment for + * the full precedence rationale. Every `legacyEnvOverride*` call below that resolves a field + * this function's shadow-consuming caller (`legacyBuildLocalDbContainerInputs`) actually + * threads onward (`dbPort`/`rootKey`/`jwtSecret`/`authJwtExpiry`/`authSiteUrl`/`anonKey`/ + * `serviceRoleKey`, plus `apiUrl`'s own `api.port`/`api.tls.enabled`/`api.external_url` + * inputs, plus `signingKeysPath`'s `auth.signing_keys_path` gate feeding the `signingKey` that + * signs `anonKey`/`serviceRoleKey` — review: PRRT_kwDOErm0O86W3Ox_) must NOT re-apply a + * `SUPABASE_*` value for a field the remote block already set — same gate + * `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` already apply for the + * OTHER shadow-bootstrap fields (review: PRRT_kwDOErm0O86W2tRi, following on from + * PRRT_kwDOErm0O86W2LL4's fix to those two). Defaults to empty: `db start`/`db reset`/ + * `status`/`stop` never resolve a remote block for this config read (they never pass a + * `projectRef`), so they are unaffected. `api.enabled`/`auth.enabled`/ + * `edge_runtime.deno_version`/`analytics.enabled`/`analytics.backend`/every + * `auth.third_party.*.enabled` are ALSO gated below even though their resolved values are + * never part of the returned `LegacyLocalConfigValues` — each one's `legacyEnvOverride*` call + * THROWS on a malformed override + * (`legacyEnvOverrideBool`/`legacyEnvOverrideDenoVersion`/`envOverrideAnalyticsBackend`) + * even when the remote block already set that field, which would abort this entire function + * (and every field it DOES return) on an env value Go silently ignores (review: + * PRRT_kwDOErm0O86W30n6 for `auth.enabled`/`analytics.*`, PRRT_kwDOErm0O86W4gCk for + * `edge_runtime.deno_version`, PRRT_kwDOErm0O86W5UlV for `api.enabled`) — "not read by the + * caller" is not the same as "cannot abort the caller." An earlier version of this comment + * claimed the remaining `studio`/`local_smtp`/`passkey`/`mfa`/hooks/`captcha`/`auth.email.smtp`/ + * `experimental.webhooks`/the auth `enable_signup`/`enable_anonymous_sign_ins`/refresh-token/ + * manual-linking/password-length/-requirements group could stay ungated because their own + * `legacyEnvOverride*` calls "cannot throw before a value the caller needs has already been + * resolved" — that reasoning doesn't hold: this function is a single synchronous call that + * either returns its whole object or throws, so ANY unconditional throw anywhere in its body + * aborts the entire call and denies the shadow every field, including ones already computed as + * local variables earlier in the function — textual position relative to a caller-needed field + * is irrelevant. All of those fields are now gated the same way as `api.enabled` above and + * tracked in `LEGACY_ENV_OVERRIDABLE_KEYS` (review: PRRT_kwDOErm0O86W6R-G). Only the fields + * whose own resolution genuinely CANNOT throw AND whose value has no Go-observable consumer + * stay ungated below: `jwtIssuer`, `additionalRedirectUrls`, the auth hooks' + * `uri`/`secrets`, the mfa phone factor's `template`/`max_frequency`, the webauthn `rp_id`/ + * `rp_origins`, the sms `template`/`max_frequency`, and the GCP analytics fields. + * `studioApiUrl` is now gated too (below) — a "non-throwing read, throwing downstream + * consumer" case like the third_party required fields just below: `legacyGoUrlParse` inside + * `legacyValidateResolvedConfig` throws on a malformed URL even though `legacyEnvOverride` + * itself never does (review: PRRT_kwDOErm0O86XKYiF's sibling gap). `studio.openai_api_key`/ + * `auth.publishable_key`/`auth.secret_key` are `config.Secret`-typed exactly like `anon_key`/ + * `service_role_key` below and are now gated the same way, having been missed when that pair + * was fixed. `auth.sms.*` (`legacyResolveAuthSms`, reached via `validateAuthSmsProviders` + * below) and `auth.external.*` (`legacyResolveAuthExternalProviders`, reached via + * `validateAuthExternalProviders` below) are threaded through and gated in their own resolvers + * now too — see those functions' own doc comments (review: PRRT_kwDOErm0O86XFmjZ, + * PRRT_kwDOErm0O86XKYiF). This function's OWN validation-only `thirdParty` + * block's non-`enabled` leaves (`requiredField`/`cognitoUserPoolRegion`) are gated too, despite + * `legacyEnvOverride` itself never throwing: each provider's per-field `validate()` + * (`config.go:1560-1629` — domain/tenant/user_pool_id/issuer_url emptiness, plus Clerk's domain + * regex) runs inside the single {@link legacyValidateResolvedConfig} call below, so an + * ungated read that picks up a stale/differently-invalid env override over a remote's own + * valid value can flip that provider's validation verdict — accepting a config Go would + * reject, or (as the reported case) rejecting one Go would accept — even though nothing + * actually throws during resolution itself (review: PRRT_kwDOErm0O86W93Ex). "Cannot throw" and + * "has no Go-observable failure mode" are different properties; this block has the former but + * not the latter. This is NOT the same `third_party` as {@link legacyResolveLocalJwks}'s/ + * {@link legacyResolveConfiguredSigningKeys}'s own, SEPARATE third-party/signing-keys + * resolution, which DOES feed the shadow's JWKS document and IS gated (see those functions' + * own doc comments). + */ + remoteOverrideKeys: ReadonlySet = new Set(), ): LegacyLocalConfigValues { + const remoteWins = (dottedFieldPath: string): boolean => remoteOverrideKeys.has(dottedFieldPath); // Go's `Config.Validate` checks `ProjectId` FIRST, before every other field // (`pkg/config/config.go:990-991`) — see this function's `@throws` doc above // for why a workdir basename that sanitizes to `""` fails here even when @@ -2622,7 +3017,16 @@ export function legacyResolveLocalConfigValues( // `SUPABASE_PROJECT_ID` is checked via the same `legacyEnvOverride` precedence // every other field here uses, since Viper's `AutomaticEnv` binds it too // (`config.go:529-535`) and it can turn an explicit-empty file value (or an - // unsanitizable basename fallback) back into a valid override. + // unsanitizable basename fallback) back into a valid override. Deliberately NOT + // gated by `remoteWins("project_id")` (unlike the fields below): the ONLY consumer + // of this value is `legacyValidateResolvedConfig`'s emptiness check + // (`legacy-config-validate.ts:336`), and `legacyEnvOverride` (a plain, non-throwing + // string read) can never turn an already non-empty remote-merged `project_id` into + // an empty one, nor vice versa — so gating here would change no observable + // accept/reject outcome. The real "shadow's network id/labels resolve the wrong + // project id" bug this pattern otherwise guards against lives in + // `legacy-local-project-context.ts`'s OWN, separately-consumed project id (see its + // doc comment — review: PRRT_kwDOErm0O86XHGDL), not this validation-only field. const resolvedProjectId = legacyEnvOverride( "SUPABASE_PROJECT_ID", config.project_id ?? legacySanitizeProjectId(basename(workdir)), @@ -2636,31 +3040,53 @@ export function legacyResolveLocalConfigValues( // `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- // `scheme://host:port` derivation (which picks `https` vs `http` from // `tls.enabled`) must be the overridden ones too. - const apiTlsEnabled = legacyEnvOverrideBool( - "SUPABASE_API_TLS_ENABLED", - config.api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ); + // A matched remote block's `api.tls.enabled` was installed at viper's OVERRIDE tier (above + // `AutomaticEnv`), so it must win over a conflicting `SUPABASE_API_TLS_ENABLED` — this field + // reaches `apiUrl`/`restUrl`/etc, which the shadow's own `db diff --linked`/`db pull` setup + // input consumes (`legacyBuildLocalDbContainerInputs`). + const apiTlsEnabled = remoteWins("api.tls.enabled") + ? config.api.tls.enabled + : legacyEnvOverrideBool( + "SUPABASE_API_TLS_ENABLED", + config.api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ); // Go's TLS cert/key validation nests entirely inside `if c.Api.Enabled` // (`config.go:1006,1010`) — mirroring `authEnabled` below, gate on the // POST-`SUPABASE_API_ENABLED`-override value, not raw `config.api.enabled`. - const apiEnabled = legacyEnvOverrideBool( - "SUPABASE_API_ENABLED", - config.api.enabled, - "api.enabled", - projectEnvValues, - ); - const apiTlsCertPath = legacyEnvOverride( - "SUPABASE_API_TLS_CERT_PATH", - config.api.tls.cert_path, - projectEnvValues, - ); - const apiTlsKeyPath = legacyEnvOverride( - "SUPABASE_API_TLS_KEY_PATH", - config.api.tls.key_path, - projectEnvValues, - ); + // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above and `authEnabled` below + // — `api.enabled` is now in `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) + // and that reader's own resolver already gates it (`legacyBlockProvidesKey(block, + // "api.enabled")`); this resolver must match, since an ungated `legacyEnvOverrideBool` call + // THROWS on a malformed `SUPABASE_API_ENABLED` even when a matched remote block already set + // `api.enabled` at viper's OVERRIDE tier — a value Go's `Validate` never even evaluates the + // env var for in that case — which would otherwise abort this whole function (and the shadow + // it feeds via `legacyBuildLocalDbContainerInputs`, denying it `apiPort`/`apiUrl`/`dbPort`/ + // `rootKey`/etc.) on an env value Go silently ignores. `apiEnabled`'s own resolved value is + // never part of the returned `LegacyLocalConfigValues` — same "throws before caller-needed + // fields are resolved" rationale as `authEnabled`/`analytics.*`/`edge_runtime.deno_version` + // below, not the "value is consumed downstream" rationale `apiTlsEnabled`/`apiPort` above have. + const apiEnabled = remoteWins("api.enabled") + ? config.api.enabled + : legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above: a matched remote + // block's `api.tls.cert_path`/`key_path` were installed at viper's OVERRIDE tier (above + // `AutomaticEnv`), so they must win over a conflicting `SUPABASE_API_TLS_CERT_PATH`/ + // `SUPABASE_API_TLS_KEY_PATH` — otherwise a stale/missing ambient env path can fail + // `readApiTlsFiles` below even though the remote block already supplied a valid path + // Go would actually use (review: PRRT_kwDOErm0O86W8ZYk). + const apiTlsCertPath = remoteWins("api.tls.cert_path") + ? config.api.tls.cert_path + : legacyEnvOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues); + const apiTlsKeyPath = remoteWins("api.tls.key_path") + ? config.api.tls.key_path + : legacyEnvOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues); if (apiEnabled && apiTlsEnabled) { readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); } @@ -2669,19 +3095,16 @@ export function legacyResolveLocalConfigValues( // below, which has no `enabled` gate. Resolved once into a named const so the // check and the URL derivation below share the same overridden value instead // of calling `legacyEnvOverridePort` twice. - const apiPort = legacyEnvOverridePort( - "SUPABASE_API_PORT", - config.api.port, - "api.port", - projectEnvValues, - ); + // Same remote-over-env precedence as `apiTlsEnabled` above. + const apiPort = remoteWins("api.port") + ? config.api.port + : legacyEnvOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues); const apiExternalUrl = legacyResolveApiExternalUrl( { - external_url: legacyEnvOverride( - "SUPABASE_API_EXTERNAL_URL", - config.api.external_url, - projectEnvValues, - ), + // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above. + external_url: remoteWins("api.external_url") + ? config.api.external_url + : legacyEnvOverride("SUPABASE_API_EXTERNAL_URL", config.api.external_url, projectEnvValues), port: apiPort, tls: { enabled: apiTlsEnabled }, }, @@ -2693,15 +3116,22 @@ export function legacyResolveLocalConfigValues( // exact message (`pkg/config/config.go:1031-1032`) before `status`/`stop` // render anything, same wording already used for the `db query`/`test db` // path (`legacy-db-config.toml-read.ts:1380`). - const dbPort = legacyEnvOverridePort( - "SUPABASE_DB_PORT", - config.db.port, - "db.port", - projectEnvValues, - ); + // Same remote-over-env precedence as `apiPort`/`apiTlsEnabled` above — `dbPort` also reaches + // `dbUrl`, consumed by the shadow's own `db diff --linked`/`db pull` setup input. + const dbPort = remoteWins("db.port") + ? config.db.port + : legacyEnvOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); // Go's `Config.Validate` checks `db.major_version` right after `db.port` - // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). - const majorVersion = legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); + // (`pkg/config/config.go:1034-1061`), unconditionally (no `enabled` gate). Validate-only here + // (this function's return type has no `majorVersion` field — the shadow's own resolved value + // comes from `legacyResolveDbBootstrapConfig`, which already gates it) — but a matched + // remote's `db.major_version` must still suppress a conflicting `SUPABASE_DB_MAJOR_VERSION` + // here too, otherwise a malformed env value the remote block should have made irrelevant + // fails this validate-only read outright before the (correctly gated) real value is ever + // reached (review: PRRT_kwDOErm0O86W2tRi). + const majorVersion = remoteWins("db.major_version") + ? config.db.major_version + : legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); // Go's `flags.LoadConfig` applies every `SUPABASE_DB_SETTINGS_*` override unconditionally // during `Config.Load` (`config.go:576-586`), BEFORE `start`/`status`/`stop` do anything else // (formerly `internal/start/start.go:51`, ran before `AssertSupabaseDbIsRunning` at line 54; @@ -2711,20 +3141,25 @@ export function legacyResolveLocalConfigValues( // `start.handler.ts`'s `bringUp` after Postgres may already be created. Validate-only: the // actual resolved settings `start` needs are recomputed at their own call site (same // "validate early, recompute at point of use" split already used for those three fields). - legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues); + // `remoteOverrideKeys` threaded through so a matched remote's `db.settings.*` value doesn't + // fail this validate-only read the same way `majorVersion` above doesn't. + legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues, remoteOverrideKeys); // Same gap for `db.network_restrictions.enabled` — `[db.network_restrictions]` ships // uncommented in Go's default template (unlike the commented-out `[db.ssl_enforcement]`) and // `NetworkRestrictions` is a plain, non-pointer `db` struct field, so Viper always registers a // default and decodes a malformed `SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED` override // unconditionally during `Config.Load` — same bucket as `db.port`/`db.major_version` above, not // the presence-gated `db.ssl_enforcement`/`auth.sms.twilio`/`auth.external.apple` cases. - // Validate-only: `start` doesn't otherwise consume this field (only `config push` does). - legacyEnvOverrideBool( - "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", - config.db.network_restrictions.enabled, - "db.network_restrictions.enabled", - projectEnvValues, - ); + // Validate-only: `start` doesn't otherwise consume this field (only `config push` does). Same + // remote-over-env precedence as `majorVersion` above. + if (!remoteWins("db.network_restrictions.enabled")) { + legacyEnvOverrideBool( + "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", + config.db.network_restrictions.enabled, + "db.network_restrictions.enabled", + projectEnvValues, + ); + } // `db.root_key` isn't modeled in `@supabase/config`'s schema (every other // `db.*` field is), so it's read off the raw pre-schema document — same // presence-based pattern as `authDocument` below. Go writes the @@ -2746,7 +3181,14 @@ export function legacyResolveLocalConfigValues( "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", ); } - const rawRootKey = legacyEnvOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); + // Same remote-over-env precedence as `apiPort`/`dbPort` above — `rootKey` reaches the + // shadow's own Postgres container spec (`legacyBuildLocalDbContainerInputs`). `rawRootKeyValue` + // already reflects a matched remote's `db.root_key` (`document` is the remote-merged raw doc — + // see `LoadedProjectConfig.document`'s own doc comment), so `remoteWins` here just means + // "don't let a conflicting `SUPABASE_DB_ROOT_KEY` clobber that already-merged value." + const rawRootKey = remoteWins("db.root_key") + ? rawRootKeyValue + : legacyEnvOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); const rootKey = rawRootKey === undefined || rawRootKey.length === 0 ? LEGACY_POSTGRES_DEFAULT_ROOT_KEY @@ -2758,56 +3200,88 @@ export function legacyResolveLocalConfigValues( // Go's `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` // ONLY when `studio.enabled` (`pkg/config/config.go:1070-1073`) — same // enabled-gated pattern as `api.port` above. - const studioEnabled = legacyEnvOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ); - const studioPort = legacyEnvOverridePort( - "SUPABASE_STUDIO_PORT", - config.studio.port, - "studio.port", - projectEnvValues, - ); + // Same remote-over-env precedence as `apiEnabled`/`apiPort` above — `studio.enabled`/ + // `studio.port` are now in `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`): + // an ungated `legacyEnvOverrideBool`/`legacyEnvOverridePort` call here THROWS on a malformed + // `SUPABASE_STUDIO_ENABLED`/`SUPABASE_STUDIO_PORT` even when a matched remote block already + // set that field at viper's OVERRIDE tier, which would abort this whole function — and the + // shadow it feeds — on an env value Go silently ignores (review: PRRT_kwDOErm0O86W6R-G). + const studioEnabled = remoteWins("studio.enabled") + ? config.studio.enabled + : legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const studioPort = remoteWins("studio.port") + ? config.studio.port + : legacyEnvOverridePort( + "SUPABASE_STUDIO_PORT", + config.studio.port, + "studio.port", + projectEnvValues, + ); // Go's `Config.Validate` parses `studio.api_url` with `net/url.Parse` right // after the port check, still inside `if c.Studio.Enabled` // (`pkg/config/config.go:1074-1078`). `config.studio.api_url` is a required // (defaulted) field, so `legacyEnvOverride` can only return `undefined` here if // that default itself were somehow undefined — the `??` fallback just // satisfies that generic signature. - const studioApiUrl = - legacyEnvOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? - config.studio.api_url; + // `legacyEnvOverride` itself never throws, but `studio.api_url` feeds + // `legacyValidateResolvedConfig`'s `legacyGoUrlParse` check below, which DOES throw on a + // malformed URL — same "non-throwing read, throwing downstream consumer" bug class already + // fixed for `legacyResolveAuthHooks`'s `uri`/`secrets` (review: PRRT_kwDOErm0O86XGTq5). An + // ungated read here can flip that validate() outcome even though nothing in this read itself + // throws, so `studio.api_url` is gated the same way as `studio.enabled`/`studio.port` above. + const studioApiUrl = remoteWins("studio.api_url") + ? config.studio.api_url + : (legacyEnvOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + config.studio.api_url); // Go's `Config.Validate` rejects `local_smtp.port === 0`/ // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct // field is still named `Inbucket` for the `[local_smtp]` TOML section // (`pkg/config/config.go:235,1081-1083`), so `local_smtp.enabled` and the // deprecated `inbucket.enabled` alias are the same underlying flag, not two // independent ones. - const mailpitEnabled = legacyEnvOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - const mailpitPort = legacyEnvOverridePort( - "SUPABASE_LOCAL_SMTP_PORT", - config.local_smtp.port, - "local_smtp.port", - projectEnvValues, - ); + // Same remote-over-env precedence as `studioEnabled`/`studioPort` above — `local_smtp.enabled`/ + // `local_smtp.port` are now in `LEGACY_ENV_OVERRIDABLE_KEYS` for the identical reason. + const mailpitEnabled = remoteWins("local_smtp.enabled") + ? config.local_smtp.enabled + : legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const mailpitPort = remoteWins("local_smtp.port") + ? config.local_smtp.port + : legacyEnvOverridePort( + "SUPABASE_LOCAL_SMTP_PORT", + config.local_smtp.port, + "local_smtp.port", + projectEnvValues, + ); + // Same remote-over-env precedence as `apiPort`/`dbPort`/`rootKey` above — `jwtSecret` reaches + // the shadow's own Postgres/fresh-DB-setup spec (`legacyBuildLocalDbContainerInputs`). const jwtSecret = resolveJwtSecret( legacyDecryptAuthSecret( - legacyEnvOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), + remoteWins("auth.jwt_secret") + ? config.auth.jwt_secret + : legacyEnvOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), projectEnvValues, ), ); - const signingKeysPath = legacyEnvOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); + // Same remote-over-env precedence as `jwtSecret` above — `signingKeysPath` gates whether + // {@link legacyResolveConfiguredSigningKeys} below produces an asymmetric `signingKey`, which + // feeds `anonKey`/`serviceRoleKey` (already remote-gated fields the shadow's setup consumes). + const signingKeysPath = remoteWins("auth.signing_keys_path") + ? config.auth.signing_keys_path + : legacyEnvOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); // Gated on `auth.enabled` to match Go's `Validate` (`pkg/config/config.go:1036,1059-1065`): // the signing-keys file read lives entirely inside `if c.Auth.Enabled`, so a // disabled auth section never opens/parses `signing_keys_path`, even a stale @@ -2817,20 +3291,34 @@ export function legacyResolveLocalConfigValues( // any other field (`config.go:582-586`), so `Validate`'s gate reads the // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence // `legacyEnvOverrideBool` here instead of `config.auth.enabled` directly. - const authEnabled = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); + // Same remote-over-env precedence as every other gated field above — `auth.enabled` IS in + // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and that reader's own + // resolver already gates it (`remoteOverrideKeys.has("auth.enabled")`); this resolver must + // match, since an ungated `legacyEnvOverrideBool` call THROWS on a malformed + // `SUPABASE_AUTH_ENABLED` even when a matched remote block already set `auth.enabled` at + // viper's OVERRIDE tier — a value Go's `Validate` never even evaluates the env var for in + // that case — which would otherwise abort this whole function (and the shadow it feeds via + // `legacyBuildLocalDbContainerInputs`) on an env value Go silently ignores + // (review: PRRT_kwDOErm0O86W30n6). + const authEnabled = remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); // Go's `Config.Validate` checks `auth.site_url` first inside `if c.Auth.Enabled` // (`pkg/config/config.go:1086-1090`), before the signing-keys read below — // `@supabase/config`'s schema only defaults `site_url` when the key is ABSENT // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as // `""` with no schema-level error, same gap as `db.port === 0` above. - const siteUrl = - legacyEnvOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? - config.auth.site_url; + // Same remote-over-env precedence as `jwtSecret` above — `siteUrl` reaches the shadow's own + // fresh-DB-setup spec (`legacyBuildLocalDbContainerInputs`'s `authSiteUrl`). + const siteUrl = remoteWins("auth.site_url") + ? config.auth.site_url + : (legacyEnvOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? + config.auth.site_url); // Go's `start.go` built GoTrue's env straight off `utils.Config.Auth.*` // with no local override logic of its own (formerly `internal/start/start.go:1365-1405`, // deleted as unreachable in CLI-1966; last present at commit a253ccba2) — the @@ -2843,12 +3331,16 @@ export function legacyResolveLocalConfigValues( config.auth.jwt_issuer, projectEnvValues, ); - const jwtExpiry = legacyEnvOverrideUint( - "SUPABASE_AUTH_JWT_EXPIRY", - "auth.jwt_expiry", - config.auth.jwt_expiry, - projectEnvValues, - ); + // Same remote-over-env precedence as `siteUrl` above — `jwtExpiry` reaches the shadow's own + // Postgres container spec (`legacyBuildLocalDbContainerInputs`'s `authJwtExpiry`). + const jwtExpiry = remoteWins("auth.jwt_expiry") + ? config.auth.jwt_expiry + : legacyEnvOverrideUint( + "SUPABASE_AUTH_JWT_EXPIRY", + "auth.jwt_expiry", + config.auth.jwt_expiry, + projectEnvValues, + ); // Go decodes `additional_redirect_urls` (a `[]string`) through the same // `StringToSliceHookFunc(",")` mapstructure hook as every other Go // string-slice field (`config.go:775-784`) — same comma-split-override @@ -2862,46 +3354,63 @@ export function legacyResolveLocalConfigValues( additionalRedirectUrlsOverride !== undefined ? additionalRedirectUrlsOverride.split(",") : config.auth.additional_redirect_urls; - const enableSignup = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_SIGNUP", - config.auth.enable_signup, - "auth.enable_signup", - projectEnvValues, - ); - const enableAnonymousSignIns = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - config.auth.enable_anonymous_sign_ins, - "auth.enable_anonymous_sign_ins", - projectEnvValues, - ); - const enableRefreshTokenRotation = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - config.auth.enable_refresh_token_rotation, - "auth.enable_refresh_token_rotation", - projectEnvValues, - ); - const refreshTokenReuseInterval = legacyEnvOverrideUint( - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "auth.refresh_token_reuse_interval", - config.auth.refresh_token_reuse_interval, - projectEnvValues, - ); - const enableManualLinking = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - config.auth.enable_manual_linking, - "auth.enable_manual_linking", - projectEnvValues, - ); - const minimumPasswordLength = legacyEnvOverrideUint( - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "auth.minimum_password_length", - config.auth.minimum_password_length, - projectEnvValues, - ); - const passwordRequirements = legacyEnvOverrideAuthPasswordRequirements( - config.auth.password_requirements, - projectEnvValues, - ); + // Same remote-over-env precedence as `studioEnabled`/`mailpitEnabled` above, for the exact same + // "throws before a value the caller needs is resolved" reason — every field in this group is + // now in `LEGACY_ENV_OVERRIDABLE_KEYS`. + const enableSignup = remoteWins("auth.enable_signup") + ? config.auth.enable_signup + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_SIGNUP", + config.auth.enable_signup, + "auth.enable_signup", + projectEnvValues, + ); + const enableAnonymousSignIns = remoteWins("auth.enable_anonymous_sign_ins") + ? config.auth.enable_anonymous_sign_ins + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", + config.auth.enable_anonymous_sign_ins, + "auth.enable_anonymous_sign_ins", + projectEnvValues, + ); + const enableRefreshTokenRotation = remoteWins("auth.enable_refresh_token_rotation") + ? config.auth.enable_refresh_token_rotation + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", + config.auth.enable_refresh_token_rotation, + "auth.enable_refresh_token_rotation", + projectEnvValues, + ); + const refreshTokenReuseInterval = remoteWins("auth.refresh_token_reuse_interval") + ? config.auth.refresh_token_reuse_interval + : legacyEnvOverrideUint( + "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", + "auth.refresh_token_reuse_interval", + config.auth.refresh_token_reuse_interval, + projectEnvValues, + ); + const enableManualLinking = remoteWins("auth.enable_manual_linking") + ? config.auth.enable_manual_linking + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", + config.auth.enable_manual_linking, + "auth.enable_manual_linking", + projectEnvValues, + ); + const minimumPasswordLength = remoteWins("auth.minimum_password_length") + ? config.auth.minimum_password_length + : legacyEnvOverrideUint( + "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", + "auth.minimum_password_length", + config.auth.minimum_password_length, + projectEnvValues, + ); + const passwordRequirements = remoteWins("auth.password_requirements") + ? config.auth.password_requirements + : legacyEnvOverrideAuthPasswordRequirements( + config.auth.password_requirements, + projectEnvValues, + ); // `LoadedProjectConfig.document` (the raw, pre-schema-default TOML `config` was decoded from) — // hoisted here (rather than inside the `authEnabled` block below, where it used to live) because // the captcha presence check right below needs it too. `undefined` for callers that haven't @@ -2911,6 +3420,7 @@ export function legacyResolveLocalConfigValues( authDocument, config.auth.captcha, projectEnvValues, + remoteOverrideKeys, ); // Go's `generateJWT` (`apikeys.go:77`) signs asymmetrically whenever // `len(a.SigningKeysPath) > 0 && len(a.SigningKeys) > 0` — NOT gated on `auth.enabled`. Since @@ -2924,9 +3434,12 @@ export function legacyResolveLocalConfigValues( // with the default key, not silently fall back to symmetric HS256. const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 - ? (legacyResolveConfiguredSigningKeys(config, workdir, projectEnvValues) ?? [ - LEGACY_DEFAULT_SIGNING_KEY, - ])[0] + ? (legacyResolveConfiguredSigningKeys( + config, + workdir, + projectEnvValues, + remoteOverrideKeys, + ) ?? [LEGACY_DEFAULT_SIGNING_KEY])[0] : undefined; // Go's `Config.Validate` runs passkey/webauthn validation, then // `Auth.Hook.validate()`, then `Auth.MFA.validate()`, then @@ -2962,8 +3475,13 @@ export function legacyResolveLocalConfigValues( // being present (`passkeyDoc`/`webauthnDoc !== undefined`), matching Go's `AutomaticEnv` // (which only intercepts keys already present in the merged config) — an absent // `[auth.passkey]`/`[auth.webauthn]` section is never synthesized from an env override alone. - const passkeyEnabled = - passkeyDoc !== undefined + // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `auth.passkey.enabled` + // is in `LEGACY_ENV_OVERRIDABLE_KEYS` because the ungated `legacyEnvOverrideBool` call below + // THROWS on a malformed override even when a matched remote block already set it, which would + // abort this whole function (and the shadow it feeds) on an env value Go silently ignores. + const passkeyEnabled = remoteWins("auth.passkey.enabled") + ? legacyRawUnmodeledBool(passkeyDoc?.["enabled"], "auth.passkey.enabled") + : passkeyDoc !== undefined ? legacyEnvOverrideBool( "SUPABASE_AUTH_PASSKEY_ENABLED", legacyRawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), @@ -3008,7 +3526,12 @@ export function legacyResolveLocalConfigValues( // `legacyResolveAuthHooks`'s unfiltered result so this validation path and // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same // per-hook override values (see that function's doc comment). - const resolvedHooks = legacyResolveAuthHooks(authDocument, config.auth.hook, projectEnvValues); + const resolvedHooks = legacyResolveAuthHooks( + authDocument, + config.auth.hook, + projectEnvValues, + remoteOverrideKeys, + ); const hooks: Array = LEGACY_HOOK_TYPE_ORDER.filter( (hookType) => resolvedHooks[LEGACY_HOOK_TYPE_TO_CAMEL[hookType]].enabled, ).map((hookType) => { @@ -3019,7 +3542,7 @@ export function legacyResolveLocalConfigValues( // Derived from `legacyResolveAuthMfa`'s unfiltered result so this validation path and // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same per-factor override // values (see that function's doc comment) — same precedent as `hooks` above. - const resolvedMfa = legacyResolveAuthMfa(config.auth.mfa, projectEnvValues); + const resolvedMfa = legacyResolveAuthMfa(config.auth.mfa, projectEnvValues, remoteOverrideKeys); const mfa: ReadonlyArray = [ { label: "totp", @@ -3042,13 +3565,17 @@ export function legacyResolveLocalConfigValues( // `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` (`config.go:1142`) — this I/O read // stays at this exact textual position (see this function's `@throws` doc for why). readAuthEmailTemplateContent( - legacyResolveAuthEmail(config.auth.email, authDocument, projectEnvValues), + legacyResolveAuthEmail(config.auth.email, authDocument, projectEnvValues, remoteOverrideKeys), workdir, ); // Go's `[auth.email.smtp]` presence-based `enabled` default — see // {@link legacyResolveAuthEmailSmtp}'s doc comment. - const resolvedSmtp = legacyResolveAuthEmailSmtp(authDocument, projectEnvValues); + const resolvedSmtp = legacyResolveAuthEmailSmtp( + authDocument, + projectEnvValues, + remoteOverrideKeys, + ); const smtp: LegacySmtpInput | undefined = resolvedSmtp === undefined ? undefined @@ -3064,8 +3591,15 @@ export function legacyResolveLocalConfigValues( // Go's `(tpa *thirdParty) validate()` fixed provider order (`pkg/config/config.go:1635-1683`) // — only enabled providers are forwarded, in that order. {@link legacyResolveThirdPartyProviders} // is the SAME hoisted resolver `commands/db/start/start.handler.ts`'s eager pre-probe battery - // calls, so both callers apply identical `SUPABASE_AUTH_THIRD_PARTY__*` overrides. - const thirdParty = legacyResolveThirdPartyProviders(config.auth.third_party, projectEnvValues); + // calls, so both callers apply identical `SUPABASE_AUTH_THIRD_PARTY__*` overrides — + // `remoteOverrideKeys` is threaded through so a matched remote's `auth.third_party.*` value + // doesn't lose to a malformed `SUPABASE_AUTH_THIRD_PARTY_*` override (review: + // PRRT_kwDOErm0O86W30n6), same reasoning as every other `remoteWins`-gated field above. + const thirdParty = legacyResolveThirdPartyProviders( + config.auth.third_party, + projectEnvValues, + remoteOverrideKeys, + ); authInput = { siteUrl: siteUrl ?? "", @@ -3083,11 +3617,18 @@ export function legacyResolveLocalConfigValues( // Go's `Config.Validate` checks `edge_runtime.deno_version` after the auth // block and the functions loop (`pkg/config/config.go:1158-1173`), and — // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no - // `edge_runtime.enabled` gate. - const denoVersion = legacyEnvOverrideDenoVersion( - config.edge_runtime.deno_version, - projectEnvValues, - ); + // `edge_runtime.enabled` gate. `edge_runtime.deno_version` is in + // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and + // `legacyEnvOverrideDenoVersion` THROWS on a malformed override — same + // `auth.enabled`/`analytics.enabled` bug class (review: PRRT_kwDOErm0O86W30n6, + // PRRT_kwDOErm0O86W4gCk): an ungated call here would abort this whole + // resolver (and the shadow it feeds) on a malformed `SUPABASE_EDGE_RUNTIME_ + // DENO_VERSION` even when a matched remote block already set + // `edge_runtime.deno_version` at viper's OVERRIDE tier, a value Go's + // `Validate` never evaluates the env var for in that case. + const denoVersion = remoteWins("edge_runtime.deno_version") + ? config.edge_runtime.deno_version + : legacyEnvOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); // Go's `Config.Validate` validates `[analytics]` right after // `edge_runtime.deno_version` (`pkg/config/config.go:1174-1187`): when @@ -3098,13 +3639,29 @@ export function legacyResolveLocalConfigValues( // `@supabase/config`'s `stringEnum` (`packages/config/src/analytics.ts:17-41`), // but that schema doesn't see the `SUPABASE_ANALYTICS_BACKEND` env-override // path — see {@link envOverrideAnalyticsBackend} for that case. - const analyticsEnabled = legacyEnvOverrideBool( - "SUPABASE_ANALYTICS_ENABLED", - config.analytics.enabled, - "analytics.enabled", + // `analytics.enabled`/`analytics.backend` are both in `LEGACY_ENV_OVERRIDABLE_KEYS` + // (`legacy-db-config.toml-read.ts`) and both THROW on a malformed override + // (`LegacyInvalidBoolEnvOverrideError`/`LegacyInvalidAnalyticsBackendEnvOverrideError`) — same + // `auth.enabled` bug class (review: PRRT_kwDOErm0O86W30n6): an ungated call here would abort + // this whole function (and the shadow it feeds) on a malformed `SUPABASE_ANALYTICS_*` env var + // even when a matched remote block already set the field at viper's OVERRIDE tier, a value + // Go's `Validate` never evaluates the env var for in that case. `gcpProjectId`/ + // `gcpProjectNumber`/`gcpJwtPath` below stay ungated: `legacyEnvOverride` (plain string) never + // throws, so there's no abort risk, and their resolved values are unused by the shadow either + // way (same "inert" reasoning as this function's other unconsumed fields). + const analyticsEnabled = remoteWins("analytics.enabled") + ? config.analytics.enabled + : legacyEnvOverrideBool( + "SUPABASE_ANALYTICS_ENABLED", + config.analytics.enabled, + "analytics.enabled", + projectEnvValues, + ); + const analyticsBackend = envOverrideAnalyticsBackend( + config.analytics.backend, projectEnvValues, + remoteWins("analytics.backend"), ); - const analyticsBackend = envOverrideAnalyticsBackend(config.analytics.backend, projectEnvValues); const gcpProjectId = legacyEnvOverride( "SUPABASE_ANALYTICS_GCP_PROJECT_ID", config.analytics.gcp_project_id, @@ -3142,18 +3699,34 @@ export function legacyResolveLocalConfigValues( // resolver just never got the equivalent treatment. A malformed JSON override needs no separate // error path here: it flows through unchanged and `legacyValidateResolvedConfig`'s existing // `isValidJson` check reports it the same way it already reports a malformed TOML-sourced value. - const webhooksEnabled = legacyEnvOverrideBool( - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - config.experimental.webhooks?.enabled === true, - "experimental.webhooks.enabled", - projectEnvValues, - ); - const pgdeltaFormatOptions = - legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", - config.experimental.pgdelta?.format_options, - projectEnvValues, - ) ?? ""; + // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `experimental. + // webhooks.enabled` is in `LEGACY_ENV_OVERRIDABLE_KEYS` because the ungated + // `legacyEnvOverrideBool` call below THROWS on a malformed override even when a matched remote + // block already set it, which would abort this whole function (and the shadow it feeds) on an + // env value Go silently ignores. + const webhooksEnabled = remoteWins("experimental.webhooks.enabled") + ? config.experimental.webhooks?.enabled === true + : legacyEnvOverrideBool( + "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", + config.experimental.webhooks?.enabled === true, + "experimental.webhooks.enabled", + projectEnvValues, + ); + // `experimental.pgdelta.format_options` is ALSO in `LEGACY_ENV_OVERRIDABLE_KEYS` + // (`legacy-db-config.toml-read.ts`), which already gates its OWN `format_options` read the + // same way (`remoteOverrideKeys.has("experimental.pgdelta.format_options")`) — this resolver's + // copy just never got the matching gate: an ungated `legacyEnvOverride` here let ambient + // `SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS` beat a matched remote's own `format_options`, + // the opposite of Go's `mergeRemoteConfig`, which installs the remote leaf with `v.Set` ABOVE + // `AutomaticEnv` (`config.go:635-640`) — same remote-over-env precedence as `webhooksEnabled` + // immediately above. + const pgdeltaFormatOptions = remoteWins("experimental.pgdelta.format_options") + ? (config.experimental.pgdelta?.format_options ?? "") + : (legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", + config.experimental.pgdelta?.format_options, + projectEnvValues, + ) ?? ""); // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own is // deferred to this single call, positioned here (where the last of those checks ran until @@ -3215,18 +3788,30 @@ export function legacyResolveLocalConfigValues( // D-only per `legacy-config-validate.ts`'s module header ("auth.external ... stays 100% inline // in D") — this is L's port of D's identical inline block. if (authEnabled) { - validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues); - validateAuthExternalProviders(authDocument, config.auth.external, projectEnvValues); + validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues, remoteOverrideKeys); + validateAuthExternalProviders( + authDocument, + config.auth.external, + projectEnvValues, + remoteOverrideKeys, + ); } - const openaiApiKey = legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_STUDIO_OPENAI_API_KEY", - config.studio.openai_api_key, - projectEnvValues, - ), - projectEnvValues, - ); + // `studio.openai_api_key` is a `config.Secret` (`pkg/config/config.go:264`), decrypted the same + // way `auth.email.smtp.pass`/`auth.captcha.secret` are — same remote-over-env precedence: an + // ungated `legacyEnvOverride` here could let a malformed ambient `SUPABASE_STUDIO_OPENAI_API_KEY` + // outrank a matched remote's own valid value and throw during decryption, aborting the whole + // call (and the shadow it feeds) on a value Go's `v.Set` (override tier) silently ignores. + const openaiApiKey = remoteWins("studio.openai_api_key") + ? legacyDecryptAuthSecret(config.studio.openai_api_key, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride( + "SUPABASE_STUDIO_OPENAI_API_KEY", + config.studio.openai_api_key, + projectEnvValues, + ), + projectEnvValues, + ); return { apiUrl: apiExternalUrl, @@ -3253,28 +3838,42 @@ export function legacyResolveLocalConfigValues( studioUrl: `http://${hostname}:${studioPort}`, mailpitUrl: `http://${hostname}:${mailpitPort}`, dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, + // `auth.publishable_key`/`auth.secret_key` (`pkg/config/auth.go:181-182`) are + // `config.Secret`-typed exactly like `anon_key`/`service_role_key` below — same + // remote-over-env precedence: an ungated `legacyEnvOverride` here could let a malformed + // ambient `SUPABASE_AUTH_PUBLISHABLE_KEY`/`SUPABASE_AUTH_SECRET_KEY` outrank a matched + // remote's own valid value and throw during decryption, aborting the whole call. publishableKey: resolveOpaqueKey( - legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_AUTH_PUBLISHABLE_KEY", - config.auth.publishable_key, - projectEnvValues, - ), - projectEnvValues, - ), + remoteWins("auth.publishable_key") + ? legacyDecryptAuthSecret(config.auth.publishable_key, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride( + "SUPABASE_AUTH_PUBLISHABLE_KEY", + config.auth.publishable_key, + projectEnvValues, + ), + projectEnvValues, + ), defaultPublishableKey, ), secretKey: resolveOpaqueKey( - legacyDecryptAuthSecret( - legacyEnvOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), - projectEnvValues, - ), + remoteWins("auth.secret_key") + ? legacyDecryptAuthSecret(config.auth.secret_key, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), + projectEnvValues, + ), defaultSecretKey, ), jwtSecret, + // Same remote-over-env precedence as `jwtSecret`/`siteUrl` above — `anonKey`/ + // `serviceRoleKey` reach the shadow's own fresh-DB-setup spec + // (`legacyBuildLocalDbContainerInputs`). anonKey: resolveSignedKey( legacyDecryptAuthSecret( - legacyEnvOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), + remoteWins("auth.anon_key") + ? config.auth.anon_key + : legacyEnvOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), projectEnvValues, ), jwtSecret, @@ -3283,11 +3882,13 @@ export function legacyResolveLocalConfigValues( ), serviceRoleKey: resolveSignedKey( legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - config.auth.service_role_key, - projectEnvValues, - ), + remoteWins("auth.service_role_key") + ? config.auth.service_role_key + : legacyEnvOverride( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + config.auth.service_role_key, + projectEnvValues, + ), projectEnvValues, ), jwtSecret, @@ -3354,18 +3955,30 @@ export function legacyResolveLocalConfigValues( * enabled, an enabled provider is missing a required field, or the remote JWKS fetch (OIDC * discovery or the JWKS document itself) fails — matching Go's `ResolveJWKS` returning that error * outright, propagated here as this file's own error type rather than a bare `Error`. + * + * `remoteOverrideKeys` (default empty, so `start.handler.ts`'s `supabase start` caller sees + * exactly the same behavior as before): every `auth.signing_keys_path`/`auth.third_party.*` + * field a matched `[remotes.]` block set at viper's OVERRIDE tier + * (`apps/cli-go/pkg/config/config.go:635-640`) must win over a conflicting `SUPABASE_AUTH_*` + * value — this function feeds the shadow's PG15+ one-shot auth-migration job's `jwks` input on + * the `db diff --linked`/`db pull` path (CLI-1956), via `legacyBuildLocalDbContainerInputs` + * (review: PRRT_kwDOErm0O86W3Ox_). */ export async function legacyResolveLocalJwks( config: ProjectConfig, workdir: string, jwtSecret: string, projectEnvValues: Readonly> | undefined = undefined, + remoteOverrideKeys: ReadonlySet = new Set(), ): Promise { - const signingKeysPath = legacyEnvOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); + const remoteWins = (dottedFieldPath: string): boolean => remoteOverrideKeys.has(dottedFieldPath); + const signingKeysPath = remoteWins("auth.signing_keys_path") + ? config.auth.signing_keys_path + : legacyEnvOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); // Go's `a.SigningKeys` is UNCONDITIONALLY seeded with the single default ES256 key at // `NewConfig()` time (`pkg/config/config.go:504-515`) — every resolved config carries it, // regardless of `auth.enabled`. It is only ever REPLACED by a configured @@ -3380,6 +3993,7 @@ export async function legacyResolveLocalJwks( config, workdir, projectEnvValues, + remoteOverrideKeys, ) ?? [LEGACY_DEFAULT_SIGNING_KEY]; // Same fixed provider order + `SUPABASE_AUTH_THIRD_PARTY__*` overrides as the @@ -3387,82 +4001,107 @@ export async function legacyResolveLocalJwks( // but built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's // `tenant_region`) rather than `LegacyThirdPartyInput` (a validation-only shape with no // `tenant_region` field) — {@link resolveThirdPartyIssuerUrl} needs the full set to build the - // issuer URL, not just validate presence. + // issuer URL, not just validate presence. Each field below prefers the remote-set value over a + // conflicting env override, same as {@link legacyResolveDbSettingsEnvOverrides}'s per-field gate. const thirdParty: ThirdPartyProvidersLike = { firebase: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - config.auth.third_party.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ), - project_id: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - config.auth.third_party.firebase.project_id, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.firebase.enabled") + ? config.auth.third_party.firebase.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + config.auth.third_party.firebase.enabled, + "auth.third_party.firebase.enabled", + projectEnvValues, + ), + project_id: remoteWins("auth.third_party.firebase.project_id") + ? config.auth.third_party.firebase.project_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", + config.auth.third_party.firebase.project_id, + projectEnvValues, + ), }, auth0: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - config.auth.third_party.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ), - tenant: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - config.auth.third_party.auth0.tenant, - projectEnvValues, - ), - tenant_region: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", - config.auth.third_party.auth0.tenant_region, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.auth0.enabled") + ? config.auth.third_party.auth0.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", + config.auth.third_party.auth0.enabled, + "auth.third_party.auth0.enabled", + projectEnvValues, + ), + tenant: remoteWins("auth.third_party.auth0.tenant") + ? config.auth.third_party.auth0.tenant + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", + config.auth.third_party.auth0.tenant, + projectEnvValues, + ), + tenant_region: remoteWins("auth.third_party.auth0.tenant_region") + ? config.auth.third_party.auth0.tenant_region + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", + config.auth.third_party.auth0.tenant_region, + projectEnvValues, + ), }, aws_cognito: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - config.auth.third_party.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ), - user_pool_id: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - config.auth.third_party.aws_cognito.user_pool_id, - projectEnvValues, - ), - user_pool_region: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - config.auth.third_party.aws_cognito.user_pool_region, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.aws_cognito.enabled") + ? config.auth.third_party.aws_cognito.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", + config.auth.third_party.aws_cognito.enabled, + "auth.third_party.aws_cognito.enabled", + projectEnvValues, + ), + user_pool_id: remoteWins("auth.third_party.aws_cognito.user_pool_id") + ? config.auth.third_party.aws_cognito.user_pool_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", + config.auth.third_party.aws_cognito.user_pool_id, + projectEnvValues, + ), + user_pool_region: remoteWins("auth.third_party.aws_cognito.user_pool_region") + ? config.auth.third_party.aws_cognito.user_pool_region + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", + config.auth.third_party.aws_cognito.user_pool_region, + projectEnvValues, + ), }, clerk: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - config.auth.third_party.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ), - domain: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - config.auth.third_party.clerk.domain, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.clerk.enabled") + ? config.auth.third_party.clerk.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + config.auth.third_party.clerk.enabled, + "auth.third_party.clerk.enabled", + projectEnvValues, + ), + domain: remoteWins("auth.third_party.clerk.domain") + ? config.auth.third_party.clerk.domain + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + config.auth.third_party.clerk.domain, + projectEnvValues, + ), }, workos: { - enabled: legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - config.auth.third_party.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ), - issuer_url: legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - config.auth.third_party.workos.issuer_url, - projectEnvValues, - ), + enabled: remoteWins("auth.third_party.workos.enabled") + ? config.auth.third_party.workos.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + config.auth.third_party.workos.enabled, + "auth.third_party.workos.enabled", + projectEnvValues, + ), + issuer_url: remoteWins("auth.third_party.workos.issuer_url") + ? config.auth.third_party.workos.issuer_url + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + config.auth.third_party.workos.issuer_url, + projectEnvValues, + ), }, }; @@ -3476,12 +4115,22 @@ export async function legacyResolveLocalJwks( // resolver here is safe/redundant-but-harmless. When auth is disabled, that earlier validation // is (correctly) skipped, so this function must NOT re-introduce it — using the unchecked, // no-throw `IssuerURL()`-only builder instead, matching Go exactly. - const authEnabled = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); + // Same remote-over-env precedence as every other field above — `auth.enabled` is in + // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and an ungated + // `legacyEnvOverrideBool` call THROWS on a malformed `SUPABASE_AUTH_ENABLED` even when a + // matched remote block already set `auth.enabled` at viper's OVERRIDE tier — a value Go's + // `Validate` never even evaluates the env var for in that case — which would otherwise abort + // this whole function (and the shadow's PG15+ one-shot auth-migration job it feeds via + // `legacyBuildLocalDbContainerInputs`) on an env value Go silently ignores + // (review: PRRT_kwDOErm0O86W30n6). + const authEnabled = remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); let issuerUrl: string | undefined; if (authEnabled) { try { diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index f793741024..24a0e3313c 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -32,8 +32,11 @@ import { legacyResolveAuthEmail, legacyResolveAuthEmailSmtp, legacyResolveAuthExternalProviders, + legacyResolveAuthExternalUrl, legacyResolveAuthHooks, + legacyResolveAuthMfa, legacyResolveAuthSms, + legacyResolveConfiguredSigningKeys, legacyResolveDbSettingsEnvOverrides, legacyResolveLocalConfigValues, legacyResolveLocalJwks, @@ -942,6 +945,28 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig(); expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); + + it("suppresses a malformed SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS when a remote block already set experimental.pgdelta.format_options (review: PRRT_kwDOErm0O86XLe6o)", () => { + // Same `experimental.webhooks.enabled` bug class, just for the OTHER Viper-bound + // `[experimental]` leaf this resolver derives: `experimental.pgdelta.format_options` is + // ALSO in `LEGACY_ENV_OVERRIDABLE_KEYS`, so a matched `[remotes.]` block's own valid + // value must win over a malformed ambient env override, matching Go's `mergeRemoteConfig` + // (`v.Set` above `AutomaticEnv`). + process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; + const config = baseConfig({ + experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["experimental.pgdelta.format_options"]), + ), + ).not.toThrow(); + }); }); describe("SUPABASE_API_TLS_ENABLED env override", () => { @@ -1284,6 +1309,95 @@ describe("legacyResolveLocalConfigValues", () => { expect(resolved?.secret).toBe("value"); delete process.env["DOTENV_PRIVATE_KEY"]; }); + + it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when a remote block already set auth.captcha.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G): same "throws before a value the caller + // needs is resolved" bug class as `studio.enabled`/`auth.enabled` above — this function's + // own ungated `legacyEnvOverrideBool` call would abort the whole + // `legacyResolveLocalConfigValues` caller (and the shadow it feeds) on a malformed + // override the remote block should have made irrelevant. + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; + const authDocument = { captcha: { enabled: false } }; + expect(() => + legacyResolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + undefined, + new Set(["auth.captcha.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; + const authDocument = { captcha: { enabled: false } }; + expect(() => + legacyResolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + undefined, + ), + ).toThrow('cannot parse "not-a-bool" as a bool'); + }); + + it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_SECRET when a remote block already set auth.captcha.secret", () => { + // Regression (review: PRRT_kwDOErm0O86XJ4HR) — same bug class as `auth.email.smtp.pass` + // (review: PRRT_kwDOErm0O86XJYol): this function's own ungated `legacyEnvOverride` call fed + // a malformed ambient override straight into `legacyDecryptAuthSecret`, which throws on an + // undecryptable `encrypted:...` value — aborting the whole `legacyResolveLocalConfigValues` + // caller (and the shadow it feeds) on an env value Go's `v.Set` (override tier, above + // `AutomaticEnv`) never lets reach decryption once a remote block already set the secret. + process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; + const authDocument = { captcha: { enabled: true } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, + undefined, + new Set(["auth.captcha.secret"]), + ); + expect(resolved?.secret).toBe("remote-secret"); + }); + + it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_SECRET when no remote block matched", () => { + process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; + const authDocument = { captcha: { enabled: true } }; + expect(() => + legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, + undefined, + ), + ).toThrow("failed to parse config: missing private key"); + }); + + it("preserves a remote block's valid auth.captcha.provider over an unsupported ambient override", () => { + // Regression (review: PRRT_kwDOErm0O86XLAYn): `provider` can't throw on its own + // (`legacyEnvOverride` is a plain string read), but an ungated override here still let a + // stale/unsupported ambient `SUPABASE_AUTH_CAPTCHA_PROVIDER` outrank a matched remote's own + // valid provider — `legacyValidateResolvedConfig`'s enum check downstream then aborts the + // whole `legacyResolveLocalConfigValues` caller (and the shadow it feeds) on a value Go's + // `v.Set` (override tier, above `AutomaticEnv`) never lets win. + process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "recaptcha"; + const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "shh" }, + undefined, + new Set(["auth.captcha.provider"]), + ); + expect(resolved?.provider).toBe("hcaptcha"); + }); + + it("still applies SUPABASE_AUTH_CAPTCHA_PROVIDER when no remote block matched", () => { + process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; + const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "shh" }, + undefined, + ); + expect(resolved?.provider).toBe("turnstile"); + }); }); describe("legacyResolveAuthEmail", () => { @@ -1337,6 +1451,7 @@ describe("legacyResolveLocalConfigValues", () => { afterEach(() => { delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]; delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"]; + delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"]; }); it("leaves every hook disabled when nothing is configured or overridden", () => { @@ -1361,6 +1476,148 @@ describe("legacyResolveLocalConfigValues", () => { const resolved = legacyResolveAuthHooks({}, allHooks, undefined); expect(resolved.customAccessToken.enabled).toBe(false); }); + + it("suppresses a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when a remote block already set that hook's enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; + const authDocument = { hook: { custom_access_token: { enabled: false } } }; + expect(() => + legacyResolveAuthHooks( + authDocument, + allHooks, + undefined, + new Set(["auth.hook.custom_access_token.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; + const authDocument = { hook: { custom_access_token: { enabled: false } } }; + expect(() => legacyResolveAuthHooks(authDocument, allHooks, undefined)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); + + it("prefers a remote-set auth.hook.custom_access_token.uri over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", () => { + // Regression (review: PRRT_kwDOErm0O86XGTq5) — Go's `mergeRemoteConfig` flattens the whole + // matched block via `u.AllKeys()` and applies EVERY leaf with `v.Set` + // (`apps/cli-go/pkg/config/config.go:718-724`), not just `enabled`. Leaving `uri` ungated + // let a stale/malformed env var beat a remote's already-merged, valid `uri`. + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + const hooksWithRemoteUri = { + ...allHooks, + custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, + }; + const authDocument = { hook: { custom_access_token: { enabled: true } } }; + const resolved = legacyResolveAuthHooks( + authDocument, + hooksWithRemoteUri, + undefined, + new Set(["auth.hook.custom_access_token.uri"]), + ); + expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); + }); + + it("prefers a remote-set auth.hook.custom_access_token.secrets over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS", () => { + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"] = "env-secret"; + const hooksWithRemoteSecrets = { + ...allHooks, + custom_access_token: { enabled: true, uri: "", secrets: "remote-secret" }, + }; + const authDocument = { hook: { custom_access_token: { enabled: true } } }; + const resolved = legacyResolveAuthHooks( + authDocument, + hooksWithRemoteSecrets, + undefined, + new Set(["auth.hook.custom_access_token.secrets"]), + ); + expect(resolved.customAccessToken.secrets).toBe("remote-secret"); + }); + + it("still applies the env override for uri when no remote block matched that leaf", () => { + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://env.example.com/hook"; + const hooksWithLocalUri = { + ...allHooks, + custom_access_token: { enabled: true, uri: "https://local.example.com/hook", secrets: "" }, + }; + const authDocument = { hook: { custom_access_token: { enabled: true } } }; + const resolved = legacyResolveAuthHooks(authDocument, hooksWithLocalUri, undefined); + expect(resolved.customAccessToken.uri).toBe("https://env.example.com/hook"); + }); + }); + + describe("legacyResolveAuthMfa — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; + }); + + it("suppresses a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when a remote block already set auth.mfa.totp.enroll_enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above: + // every `auth.mfa.*` leaf here is unconditionally resolved by + // `legacyResolveLocalConfigValues` (inside its `authEnabled` block), so an ungated call + // would abort that whole caller on a malformed override the remote block should have made + // irrelevant. + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; + const mfa = baseConfig().auth.mfa; + expect(() => + legacyResolveAuthMfa(mfa, undefined, new Set(["auth.mfa.totp.enroll_enabled"])), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; + const mfa = baseConfig().auth.mfa; + expect(() => legacyResolveAuthMfa(mfa, undefined)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); + }); + + describe("legacyResolveAuthEmailSmtp — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; + delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; + }); + + it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when a remote block already set auth.email.smtp.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. + process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; + const authDocument = { email: { smtp: { enabled: true } } }; + expect(() => + legacyResolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.enabled"])), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; + const authDocument = { email: { smtp: { enabled: true } } }; + expect(() => legacyResolveAuthEmailSmtp(authDocument, undefined)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when a remote block already set auth.email.smtp.pass", () => { + // Regression (review: PRRT_kwDOErm0O86XJYol) — same bug class as `.enabled`/`.port` + // above, just for this Secret-typed leaf: an ungated env override reached + // `legacyDecryptAuthSecret` and threw before the remote's own valid `pass` was used. + process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; + const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + undefined, + new Set(["auth.email.smtp.pass"]), + ); + expect(resolved?.pass).toBe("remote-pass"); + }); + + it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; + const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; + expect(() => legacyResolveAuthEmailSmtp(authDocument, undefined)).toThrow( + "failed to parse config: missing private key", + ); + }); }); describe("legacyResolveAuthExternalProviders", () => { @@ -1467,6 +1724,83 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("legacyResolveAuthExternalProviders — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // Regression (review: PRRT_kwDOErm0O86XKYiF): this resolver had no `remoteOverrideKeys` + // parameter at all, so a matched `[remotes.]` block's own valid `auth.external..*` + // value could always lose to a conflicting/malformed ambient `SUPABASE_AUTH_EXTERNAL__*` + // override — `secret`/`enabled`/`skip_nonce_check`/`email_optional` can additionally THROW on + // a malformed override, aborting the whole `legacyResolveLocalConfigValues` caller (and the + // shadow it feeds). + it("prefers a remote-set auth.external..secret over a malformed SUPABASE_AUTH_EXTERNAL__SECRET", () => { + const authDocument = { + external: { my_custom: { enabled: true, secret: "remote-secret" } }, + }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; + const resolved = legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.secret"]), + ); + expect(resolved["my_custom"]?.secret).toBe("remote-secret"); + }); + + it("still rejects a malformed SUPABASE_AUTH_EXTERNAL__SECRET when no remote block matched", () => { + const authDocument = { + external: { my_custom: { enabled: true, secret: "remote-secret" } }, + }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + ), + ).toThrow("failed to parse config: missing private key"); + }); + + it("prefers a remote-set auth.external..enabled over a malformed SUPABASE_AUTH_EXTERNAL__ENABLED", () => { + const authDocument = { external: { my_custom: { enabled: true } } }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_EXTERNAL__ENABLED when no remote block matched", () => { + const authDocument = { external: { my_custom: { enabled: true } } }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + ), + ).toThrow('cannot parse "not-a-bool" as a bool'); + }); + + it("prefers a remote-set auth.external..client_id over a conflicting SUPABASE_AUTH_EXTERNAL__CLIENT_ID", () => { + const authDocument = { + external: { my_custom: { enabled: true, client_id: "remote-client-id" } }, + }; + const projectEnvValues = { + SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_CLIENT_ID: "env-should-not-win", + }; + const resolved = legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.client_id"]), + ); + expect(resolved["my_custom"]?.clientId).toBe("remote-client-id"); + }); + }); + describe("legacyRawUnmodeledBool", () => { it("returns false for an absent value, matching Go's zero-value bool default", () => { expect(legacyRawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); @@ -2438,6 +2772,101 @@ describe("legacyResolveLocalConfigValues", () => { legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), ).not.toThrow(); }); + + it("preserves a remote block's valid template content_path over a missing-file ambient override", () => { + // Regression (review: PRRT_kwDOErm0O86XLAYn): `content_path` is the field that can + // actually abort resolution here — an ungated override let a stale/missing ambient + // `_CONTENT_PATH` outrank a matched remote's own valid path, and the caller-side file read + // (`readAuthEmailTemplateContent`) then threw, aborting the whole + // `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a value Go's `v.Set` + // (override tier, above `AutomaticEnv`) never lets win. + writeFileSync(join(tempRoot.current, "invite.html"), ""); + process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.template.invite.content_path"]), + ), + ).not.toThrow(); + }); + + it("still applies a template _CONTENT_PATH override to a missing file when no remote block matched", () => { + process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "Invalid config for auth.email.template.invite.content_path: ", + ); + }); + + it("preserves a remote block's valid notification content_path over a missing-file ambient override", () => { + const supabaseDir = join(tempRoot.current, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "pw-changed.html"), ""); + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = + "missing.html"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: true, content_path: "pw-changed.html" }, + }, + }, + }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.notification.password_changed.content_path"]), + ), + ).not.toThrow(); + }); + + it("suppresses a malformed ambient notification _ENABLED when a remote block already set enabled", () => { + // `enabled` is a direct `legacyEnvOverrideBool` call, so a malformed ambient override + // throws on its own regardless of the exclusivity/file-read checks above — same bug class + // as `auth.email.enable_signup`/`.enable_confirmations` (review: PRRT_kwDOErm0O86XLAYo). + process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { notification: { password_changed: { enabled: false } } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.notification.password_changed.enabled"]), + ), + ).not.toThrow(); + }); }); // auth.third_party.* (thirdParty.validate()) and functions.* (function-slug validation) @@ -2693,6 +3122,136 @@ describe("legacyResolveLocalConfigValues", () => { }); }); + describe("legacyResolveAuthSms — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // Regression (review: PRRT_kwDOErm0O86XFmjZ) — a prior review rejected this exact gap as + // "unreachable from the db diff --linked/db pull shadow path," having only grepped direct + // `legacyResolveAuthSms(` call sites in `start.handler.ts`/`db/start/start.handler.ts` and + // missed that `legacyResolveLocalConfigValues` (this function's own shadow-consuming caller, + // via `legacyBuildLocalDbContainerInputs`) calls it too, through its own + // `validateAuthSmsProviders` wrapper, whenever `authEnabled`. `enable_signup`/ + // `enable_confirmations`/each provider's `enabled` THROW via `legacyEnvOverrideBool`, and each + // provider's Secret-typed field THROWS via `legacyDecryptAuthSecret` — either can abort the + // whole `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a malformed ambient + // override even when a matched remote block already set that field. + afterEach(() => { + delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; + delete process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"]; + delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"]; + }); + + it("suppresses a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when a remote block already set auth.sms.enable_signup", () => { + process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + const configured = { + ...baseConfig().auth.sms, + enable_signup: true, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, + }; + expect(() => + legacyResolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.enable_signup"])), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when no remote block matched", () => { + process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + const configured = { + ...baseConfig().auth.sms, + enable_signup: true, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, + }; + expect(() => legacyResolveAuthSms(undefined, configured, undefined)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_SMS_VONAGE_ENABLED when a remote block already set auth.sms.vonage.enabled", () => { + process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"] = "not-a-bool"; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, + }; + expect(() => + legacyResolveAuthSms( + undefined, + configured, + undefined, + new Set(["auth.sms.vonage.enabled"]), + ), + ).not.toThrow(); + }); + + it("prefers a remote-set auth.sms.vonage.api_secret over a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET", () => { + process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, + }; + const resolved = legacyResolveAuthSms( + undefined, + configured, + undefined, + new Set(["auth.sms.vonage.enabled", "auth.sms.vonage.api_secret"]), + ); + expect(resolved.vonage.api_secret).toBe("remote-secret"); + }); + + it("still rejects a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET when no remote block matched", () => { + // `vonage` isn't `twilio` (the one provider Go's default template always registers), so the + // env override is only consulted at all when the raw `[auth.sms.vonage]` table is present — + // same presence gate `providerPresent` already applies for the remote-set case above. + process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; + const authDocument = { sms: { vonage: {} } }; + const configured = { + ...baseConfig().auth.sms, + vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, + }; + expect(() => legacyResolveAuthSms(authDocument, configured, undefined)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("still aborts legacyResolveLocalConfigValues on a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP reached via validateAuthSmsProviders, unless remoteOverrideKeys suppresses it", () => { + // End-to-end proof that the gap is reachable from the exact function this PR's shadow + // provisioning calls (`legacyBuildLocalDbContainerInputs` -> `legacyResolveLocalConfigValues` + // -> `validateAuthSmsProviders` -> `legacyResolveAuthSms`), not just the standalone resolver. + // Built by spreading an already-decoded `baseConfig()` (not re-decoding through + // `ProjectConfigSchema` via `baseConfig({...})`'s shallow-merge overrides) so `vonage`'s + // other schema-required fields (`from`, etc.) keep their valid decoded defaults. + process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + const base = baseConfig(); + const config: ProjectConfig = { + ...base, + auth: { + ...base.auth, + enabled: true, + sms: { + ...base.auth.sms, + enable_signup: true, + vonage: { + ...base.auth.sms.vonage, + enabled: true, + from: "12345", + api_key: "key", + api_secret: "secret", + }, + }, + }, + }; + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.sms.enable_signup"]), + ), + ).not.toThrow(); + }); + }); + describe("api.tls (cert/key validation)", () => { const tempRoot = useLegacyTempWorkdir("supabase-api-tls-test-"); @@ -2806,13 +3365,728 @@ describe("legacyResolveLocalConfigValues", () => { }); }); -describe("legacyResolveLocalJwks", () => { - const tempRoot = useLegacyTempWorkdir("supabase-local-jwks-test-"); - - it("includes the default ES256 signing key and the oct JWT-secret fallback when no signing_keys_path is configured", async () => { - // Go's `a.SigningKeys` defaults to this single ES256 key at `NewConfig()` time - // (`pkg/config/config.go:504-515`), unconditionally — `ResolveJWKS` always publishes it - // (in public form) unless a configured `signing_keys_path` file overrides it. +describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // Go's `mergeRemoteConfig` installs every matched `[remotes.]` leaf at viper's OVERRIDE + // tier, above `AutomaticEnv` (`apps/cli-go/pkg/config/config.go:635-640`) — so once a remote + // block sets a field, a conflicting `SUPABASE_*` env var must never be consulted for it. + // `legacyResolveDbBootstrapConfig`/`legacyResolveDbSettingsEnvOverrides` already gated this + // (review: PRRT_kwDOErm0O86W2LL4); this covers the remaining leaves this resolver derives + // that the shadow's own container/setup spec also consumes (review: PRRT_kwDOErm0O86W2tRi). + afterEach(() => { + for (const name of [ + "SUPABASE_DB_MAJOR_VERSION", + "SUPABASE_AUTH_JWT_SECRET", + "SUPABASE_DB_ROOT_KEY", + "SUPABASE_API_PORT", + "SUPABASE_API_TLS_ENABLED", + "SUPABASE_API_EXTERNAL_URL", + "SUPABASE_DB_PORT", + "SUPABASE_AUTH_SITE_URL", + "SUPABASE_AUTH_JWT_EXPIRY", + "SUPABASE_AUTH_ANON_KEY", + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "SUPABASE_STUDIO_API_URL", + "SUPABASE_STUDIO_OPENAI_API_KEY", + "SUPABASE_AUTH_PUBLISHABLE_KEY", + "SUPABASE_AUTH_SECRET_KEY", + "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + "SUPABASE_AUTH_ENABLED", + "SUPABASE_ANALYTICS_ENABLED", + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + "SUPABASE_EDGE_RUNTIME_DENO_VERSION", + "SUPABASE_API_ENABLED", + "SUPABASE_STUDIO_ENABLED", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_ENABLED", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_AUTH_ENABLE_SIGNUP", + "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", + "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", + "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", + "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", + "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", + "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", + "SUPABASE_AUTH_PASSKEY_ENABLED", + "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", + "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", + ]) { + delete process.env[name]; + } + }); + + const tempRoot = useLegacyTempWorkdir("supabase-remote-signing-keys-test-"); + + it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { + // Regression (review: PRRT_kwDOErm0O86W3Ox_): `legacyResolveConfiguredSigningKeys` — shared + // by this function's own `anonKey`/`serviceRoleKey` asymmetric signing and by + // `legacyResolveLocalJwks` — used to reapply a conflicting env override even when a remote + // block already set `auth.signing_keys_path`, which would have pointed the shadow's + // asymmetric signing at the wrong (env-supplied) file. + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.signing_keys_path"]), + ), + ).not.toThrow(); + }); + + it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", () => { + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read signing keys: ", + ); + }); + + it("suppresses a malformed SUPABASE_DB_MAJOR_VERSION when a remote block already set db.major_version", () => { + // Regression (review: PRRT_kwDOErm0O86W2tRi): this function validates `db.major_version` + // early but has no `majorVersion` field on its own return type (the shadow's actually- + // consumed value comes from the already-gated `legacyResolveDbBootstrapConfig`) — before + // this fix, the validate-only read here still decoded a conflicting env var unconditionally, + // so a malformed value the remote block should have made irrelevant failed config loading + // outright instead of the command proceeding on the remote's value, matching Go. + process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + const config = baseConfig({ db: { major_version: 14 } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["db.major_version"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_DB_MAJOR_VERSION when no remote block matched", () => { + process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + const config = baseConfig({ db: { major_version: 14 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid db.major_version: abc", + ); + }); + + it("prefers a remote-set auth.jwt_secret over a conflicting SUPABASE_AUTH_JWT_SECRET", () => { + process.env["SUPABASE_AUTH_JWT_SECRET"] = "env-supplied-secret-value-1234567890"; + const config = baseConfig({ auth: { jwt_secret: "remote-supplied-secret-1234567890" } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.jwt_secret"]), + ); + expect(values.jwtSecret).toBe("remote-supplied-secret-1234567890"); + }); + + it("prefers a remote-set db.root_key over a conflicting SUPABASE_DB_ROOT_KEY", () => { + process.env["SUPABASE_DB_ROOT_KEY"] = "env-root-key"; + const config = baseConfig(); + const document = { db: { root_key: "remote-root-key" } }; + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["db.root_key"]), + ); + expect(values.rootKey).toBe("remote-root-key"); + }); + + it("prefers a remote-set auth.third_party.clerk.domain over a conflicting env override during validation", () => { + // Regression (review: PRRT_kwDOErm0O86W93Ex): this function's OWN validation-only + // `thirdParty` array used to gate `enabled` on `remoteWins` but leave the sibling + // `requiredField` (domain/tenant/user_pool_id/issuer_url) ungated — even though + // `auth.third_party.clerk.domain` is already tracked in `LEGACY_ENV_OVERRIDABLE_KEYS`. A + // matched remote's valid domain lost to a conflicting, invalid `SUPABASE_AUTH_THIRD_PARTY_ + // CLERK_DOMAIN`, so `legacyValidateResolvedConfig`'s Clerk domain-regex check rejected an + // otherwise-valid, remote-backed configuration before the shadow was ever created — Go's + // `mergeRemoteConfig` sets the whole matched block at viper's OVERRIDE tier, above + // `AutomaticEnv`, so the env var is never even consulted once a remote sets this key. + process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED"] = "false"; + process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; + const config = baseConfig({ + auth: { + enabled: true, + third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, + }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.third_party.clerk.enabled", "auth.third_party.clerk.domain"]), + ), + ).not.toThrow(); + }); + + it("still rejects a conflicting SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN when no remote block matched", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; + const config = baseConfig({ + auth: { + enabled: true, + third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, + }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.clerk has invalid domain", + ); + }); + + describe("api.tls.cert_path/key_path — remoteOverrideKeys (review: PRRT_kwDOErm0O86W8ZYk)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-api-tls-remote-test-"); + + function writeTlsFile(workdir: string, name: string, contents = "dummy") { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, name), contents); + } + + afterEach(() => { + delete process.env["SUPABASE_API_TLS_CERT_PATH"]; + delete process.env["SUPABASE_API_TLS_KEY_PATH"]; + }); + + it("prefers a remote-set api.tls.cert_path/key_path over a conflicting (missing-file) env override", () => { + // The ambient env vars point at files that don't exist — if they won, `readApiTlsFiles` + // would throw. Go's `mergeRemoteConfig` installs the matched remote block's cert/key + // paths at viper's OVERRIDE tier (above `AutomaticEnv`), so they must win instead and the + // load must succeed using the real, remote-supplied paths. + writeTlsFile(tempRoot.current, "cert.pem"); + writeTlsFile(tempRoot.current, "key.pem"); + process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; + process.env["SUPABASE_API_TLS_KEY_PATH"] = "missing-key.pem"; + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["api.tls.cert_path", "api.tls.key_path"]), + ), + ).not.toThrow(); + }); + + it("still uses the env override when no remote block matched", () => { + writeTlsFile(tempRoot.current, "cert.pem"); + process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "cert.pem" } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + "failed to read TLS cert: ", + ); + }); + }); + + it("prefers remote-set api.port/api.tls.enabled/api.external_url over conflicting env overrides", () => { + process.env["SUPABASE_API_PORT"] = "9999"; + process.env["SUPABASE_API_TLS_ENABLED"] = "true"; + process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-should-not-win.test"; + const config = baseConfig({ api: { port: 54321, external_url: "", tls: { enabled: false } } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["api.port", "api.tls.enabled", "api.external_url"]), + ); + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + }); + + it("prefers a remote-set db.port over a conflicting SUPABASE_DB_PORT", () => { + process.env["SUPABASE_DB_PORT"] = "9999"; + const config = baseConfig({ db: { port: 54322 } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["db.port"]), + ); + expect(values.dbPort).toBe(54322); + expect(values.dbUrl).toContain(":54322/postgres"); + }); + + it("prefers remote-set auth.site_url/auth.jwt_expiry over conflicting env overrides", () => { + process.env["SUPABASE_AUTH_SITE_URL"] = "https://env-should-not-win.test"; + process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "9999"; + const config = baseConfig({ auth: { site_url: "https://remote.test", jwt_expiry: 3600 } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.site_url", "auth.jwt_expiry"]), + ); + expect(values.authSiteUrl).toBe("https://remote.test"); + expect(values.authJwtExpiry).toBe(3600); + }); + + it("prefers remote-set auth.anon_key/auth.service_role_key over conflicting env overrides", () => { + process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon-key"; + process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role-key"; + const config = baseConfig({ + auth: { anon_key: "remote-anon-key", service_role_key: "remote-service-role-key" }, + }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.anon_key", "auth.service_role_key"]), + ); + expect(values.anonKey).toBe("remote-anon-key"); + expect(values.serviceRoleKey).toBe("remote-service-role-key"); + }); + + it("suppresses a malformed SUPABASE_STUDIO_API_URL when a remote block already set studio.api_url", () => { + // Regression (review: PRRT_kwDOErm0O86XKYiF's sibling gap): `studio.api_url` feeds + // `legacyValidateResolvedConfig`'s `legacyGoUrlParse` check, which throws on a malformed URL + // even though the read itself (`legacyEnvOverride`) never does — same "non-throwing read, + // throwing downstream consumer" bug class as `legacyResolveAuthHooks`'s `uri`/`secrets`. + process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; + const config = baseConfig({ studio: { api_url: "http://remote.test" } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.api_url"]), + ), + ).not.toThrow(); + }); + + it("prefers a remote-set studio.openai_api_key over a conflicting SUPABASE_STUDIO_OPENAI_API_KEY", () => { + // Regression: `studio.openai_api_key` is a `config.Secret` (`pkg/config/config.go:264`), + // decrypted the same way `anon_key`/`service_role_key` above are — an ungated + // `legacyEnvOverride` here could let a malformed ambient override outrank a matched remote's + // own valid value and throw during decryption. + process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.openai_api_key"]), + ); + expect(values.openaiApiKey).toBe("remote-openai-key"); + }); + + it("still rejects a malformed SUPABASE_STUDIO_OPENAI_API_KEY when no remote block matched", () => { + process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("prefers remote-set auth.publishable_key/auth.secret_key over conflicting env overrides", () => { + // Regression: `auth.publishable_key`/`auth.secret_key` (`pkg/config/auth.go:181-182`) are + // `config.Secret`-typed exactly like `anon_key`/`service_role_key` above, but were missed + // when that sibling pair was gated. + process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; + process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ + auth: { publishable_key: "remote-publishable-key", secret_key: "remote-secret-key" }, + }); + const values = legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.publishable_key", "auth.secret_key"]), + ); + expect(values.publishableKey).toBe("remote-publishable-key"); + expect(values.secretKey).toBe("remote-secret-key"); + }); + + it("still rejects a malformed SUPABASE_AUTH_PUBLISHABLE_KEY when no remote block matched", () => { + process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ auth: { publishable_key: "remote-publishable-key" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("still rejects a malformed SUPABASE_AUTH_SECRET_KEY when no remote block matched", () => { + process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; + const config = baseConfig({ auth: { secret_key: "remote-secret-key" } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "failed to parse config: missing private key", + ); + }); + + it("suppresses a malformed SUPABASE_DB_SETTINGS_MAX_CONNECTIONS when the remote block set db.settings.max_connections", () => { + // Same validate-only shape as `db.major_version` above — `legacyResolveDbSettingsEnvOverrides` + // is threaded `remoteOverrideKeys` here too, not just at its OWN (already-gated) call site + // in `legacyResolveDbBootstrapConfig`. + process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; + const config = baseConfig({ db: { settings: { max_connections: 100 } } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["db.settings.max_connections"]), + ), + ).not.toThrow(); + }); + + it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W30n6): `auth.enabled` gates the signing-keys file + // read/validate-only auth block below but has no `authEnabled` field on its own return type — + // before this fix, the ungated `legacyEnvOverrideBool` call still decoded a conflicting env + // var unconditionally, so a malformed value the remote block should have made irrelevant + // failed this WHOLE function (and therefore the shadow's `dbPort`/`jwtSecret`/etc. it also + // resolves) instead of the command proceeding on the remote's value, matching Go. + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_ANALYTICS_ENABLED when a remote block already set analytics.enabled", () => { + // Same class of gap as `auth.enabled` above — `analytics.enabled` is also in + // `LEGACY_ENV_OVERRIDABLE_KEYS` and `analyticsEnabled` is never read by the shadow's own + // container inputs, but an ungated `legacyEnvOverrideBool` call still aborts this whole + // function on a malformed override the remote block should have made irrelevant. + process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ analytics: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["analytics.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_ANALYTICS_ENABLED when no remote block matched", () => { + process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ analytics: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for analytics.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when a remote block already set auth.third_party.firebase.enabled", () => { + // Same class of gap as `auth.enabled`/`analytics.enabled` above, for this function's OWN + // validation-only `thirdParty` block (distinct from `legacyResolveLocalJwks`'s own, already- + // gated `thirdParty` — see that param's doc comment). Auth must be enabled for this block to + // run at all. + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { enabled: true, third_party: { firebase: { enabled: false } } }, + }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.enabled", "auth.third_party.firebase.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; + const config = baseConfig({ + auth: { enabled: true, third_party: { firebase: { enabled: false } } }, + }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for auth.third_party.firebase.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when a remote block already set edge_runtime.deno_version", () => { + // Regression (review: PRRT_kwDOErm0O86W4gCk): same class of gap as `auth.enabled`/ + // `analytics.enabled` above — `edge_runtime.deno_version` is also in + // `LEGACY_ENV_OVERRIDABLE_KEYS` and `denoVersion` is never read by the shadow's own + // container inputs, but an ungated `legacyEnvOverrideDenoVersion` call still aborts this + // whole function on a malformed override the remote block should have made irrelevant. + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["edge_runtime.deno_version"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when no remote block matched", () => { + process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: abc.", + ); + }); + + it("suppresses a malformed SUPABASE_API_ENABLED when a remote block already set api.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W5UlV): same class of gap as `auth.enabled`/ + // `analytics.enabled`/`edge_runtime.deno_version` above — `api.enabled` is also in + // `LEGACY_ENV_OVERRIDABLE_KEYS` and `apiEnabled` is never read by the shadow's own + // container inputs (unlike its siblings `apiTlsEnabled`/`apiPort`, which feed `apiUrl`), + // but an ungated `legacyEnvOverrideBool` call still aborts this whole function — denying + // it `apiPort`/`apiUrl`/`dbPort`/`rootKey`/etc. too — on a malformed override the remote + // block should have made irrelevant. + process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; + const config = baseConfig({ api: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["api.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_API_ENABLED when no remote block matched", () => { + process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; + const config = baseConfig({ api: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for api.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_STUDIO_ENABLED when a remote block already set studio.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G): the doc comment on this function's + // `remoteOverrideKeys` parameter used to claim `studio`/`local_smtp`/the auth + // enable_signup/-anonymous_sign_ins/refresh-token/manual-linking/password-length/ + // -requirements group/passkey/hooks/mfa/captcha/email.smtp/experimental.webhooks fields could + // stay ungated because their own `legacyEnvOverride*` calls "cannot throw before a value the + // caller needs has already been resolved" — that's false: this function either returns its + // whole object or throws, so ANY unconditional throw anywhere in its body aborts the entire + // call, denying the shadow `dbPort`/`jwtSecret`/etc. too, regardless of textual position. + process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; + const config = baseConfig({ studio: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_STUDIO_ENABLED when no remote block matched", () => { + process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; + const config = baseConfig({ studio: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for studio.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_STUDIO_PORT when a remote block already set studio.port", () => { + process.env["SUPABASE_STUDIO_PORT"] = "not-a-port"; + const config = baseConfig({ studio: { port: 54323 } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["studio.port"]), + ), + ).not.toThrow(); + }); + + it("suppresses a malformed SUPABASE_LOCAL_SMTP_ENABLED when a remote block already set local_smtp.enabled", () => { + process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; + const config = baseConfig({ local_smtp: { enabled: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["local_smtp.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_LOCAL_SMTP_ENABLED when no remote block matched", () => { + process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; + const config = baseConfig({ local_smtp: { enabled: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for local_smtp.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_ENABLE_SIGNUP when a remote block already set auth.enable_signup", () => { + process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; + const config = baseConfig({ auth: { enable_signup: false } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.enable_signup"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_ENABLE_SIGNUP when no remote block matched", () => { + process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; + const config = baseConfig({ auth: { enable_signup: false } }); + expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for auth.enable_signup: cannot parse "not-a-bool" as a bool', + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH when a remote block already set auth.minimum_password_length", () => { + process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "not-a-number"; + const config = baseConfig({ auth: { minimum_password_length: 8 } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.minimum_password_length"]), + ), + ).not.toThrow(); + }); + + it("suppresses a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when a remote block already set experimental.webhooks.enabled", () => { + process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "not-a-bool"; + const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["experimental.webhooks.enabled"]), + ), + ).not.toThrow(); + }); + + it("suppresses a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when a remote block already set that hook's uri", () => { + // Regression (review: PRRT_kwDOErm0O86XGTq5): the remote can supply a valid `uri` while a + // stale/malformed `SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI` sits in the ambient + // environment. Go's `mergeRemoteConfig` (`config.go:718-724`) sets EVERY matched-block leaf + // above `AutomaticEnv`, so the remote's valid uri must win and validation must pass — before + // this fix, the ungated env read won instead and `legacyValidateResolvedConfig`'s scheme + // check rejected a linked diff/pull that Go would have accepted. + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + const config = baseConfig({ + auth: { + hook: { + custom_access_token: { + enabled: true, + uri: "https://example.com/hook", + secrets: `v1,whsec_${"A".repeat(32)}`, + }, + }, + }, + }); + const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + document, + new Set(["auth.hook.custom_access_token.uri"]), + ), + ).not.toThrow(); + }); + + it("still rejects a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when no remote block matched that leaf", () => { + process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + const config = baseConfig({ + auth: { + hook: { + custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, + }, + }, + }); + const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; + expect(() => + legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); + }); +}); + +describe("legacyResolveLocalJwks", () => { + const tempRoot = useLegacyTempWorkdir("supabase-local-jwks-test-"); + + it("includes the default ES256 signing key and the oct JWT-secret fallback when no signing_keys_path is configured", async () => { + // Go's `a.SigningKeys` defaults to this single ES256 key at `NewConfig()` time + // (`pkg/config/config.go:504-515`), unconditionally — `ResolveJWKS` always publishes it + // (in public form) unless a configured `signing_keys_path` file overrides it. const config = baseConfig(); const jwks = await legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32)); expect(JSON.parse(jwks)).toEqual({ @@ -3051,4 +4325,191 @@ describe("legacyResolveLocalJwks", () => { ); }); }); + + describe("remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // Go's `mergeRemoteConfig` installs every matched `[remotes.]` leaf at viper's OVERRIDE + // tier, above `AutomaticEnv` (`apps/cli-go/pkg/config/config.go:635-640`) — regression + // coverage for review PRRT_kwDOErm0O86W3Ox_, which found `auth.signing_keys_path`/ + // `auth.third_party.*` reapplying a conflicting `SUPABASE_AUTH_*` env value even after a + // matched remote block set them. + afterEach(() => { + for (const name of [ + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + "SUPABASE_AUTH_ENABLED", + ]) { + delete process.env[name]; + } + }); + + it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", async () => { + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = await legacyResolveLocalJwks( + config, + tempRoot.current, + "a".repeat(32), + undefined, + new Set(["auth.signing_keys_path"]), + ); + const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; + expect(parsed.keys).toHaveLength(1); + expect(parsed.keys[0]).toMatchObject({ kty: "RSA", kid: "test-rsa-kid" }); + }); + + it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", async () => { + writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + await expect( + legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32)), + ).rejects.toThrow("failed to read signing keys: "); + }); + + it("prefers a remote-set auth.third_party.workos.* over conflicting env overrides", async () => { + const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://remote-issuer.example/.well-known/openid-configuration") { + return new Response( + JSON.stringify({ jwks_uri: "https://remote-issuer.example/jwks.json" }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (url === "https://remote-issuer.example/jwks.json") { + return new Response(JSON.stringify({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + throw new Error(`unexpected fetch url: ${url}`); + }); + process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED"] = "false"; + process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL"] = + "https://env-should-not-win.test"; + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://remote-issuer.example" } }, + }, + }); + const jwks = await legacyResolveLocalJwks( + config, + WORKDIR, + "a".repeat(32), + undefined, + new Set(["auth.third_party.workos.enabled", "auth.third_party.workos.issuer_url"]), + ); + const parsed = JSON.parse(jwks) as { keys: ReadonlyArray> }; + expect(parsed.keys.some((key) => key["kid"] === "remote-key")).toBe(true); + fetchMock.mockRestore(); + }); + + it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", async () => { + // Regression (review: PRRT_kwDOErm0O86W30n6): this function recomputes `authEnabled` + // itself (see its own doc comment) to gate `resolveThirdPartyIssuerUrl`'s throwing validate + // path — before this fix, the ungated `legacyEnvOverrideBool` call still decoded a + // conflicting env var unconditionally, so a malformed value the remote block should have + // made irrelevant failed the shadow's PG15+ one-shot auth-migration job outright. + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + await expect( + legacyResolveLocalJwks( + config, + WORKDIR, + "a".repeat(32), + undefined, + new Set(["auth.enabled"]), + ), + ).resolves.toEqual(expect.any(String)); + }); + + it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", async () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + await expect(legacyResolveLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( + 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + ); + }); + }); +}); + +describe("legacyResolveAuthExternalUrl — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + delete process.env["SUPABASE_AUTH_EXTERNAL_URL"]; + }); + + it("prefers a remote-set auth.external_url over a conflicting SUPABASE_AUTH_EXTERNAL_URL", () => { + process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-should-not-win.test"; + const document = { auth: { external_url: "https://remote.test" } }; + expect(legacyResolveAuthExternalUrl(document, undefined, new Set(["auth.external_url"]))).toBe( + "https://remote.test", + ); + }); + + it("still applies SUPABASE_AUTH_EXTERNAL_URL when no remote block matched", () => { + process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-wins.test"; + const document = { auth: { external_url: "https://configured.test" } }; + expect(legacyResolveAuthExternalUrl(document, undefined)).toBe("https://env-wins.test"); + }); +}); + +describe("legacyResolveConfiguredSigningKeys — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-configured-signing-keys-test-"); + + afterEach(() => { + delete process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"]; + delete process.env["SUPABASE_AUTH_ENABLED"]; + }); + + it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const keys = legacyResolveConfiguredSigningKeys( + config, + tempRoot.current, + undefined, + new Set(["auth.signing_keys_path"]), + ); + expect(keys).toHaveLength(1); + expect(keys?.[0]).toMatchObject({ kid: "test-rsa-kid" }); + }); + + it("still reads the env-overridden path when no remote block matched", () => { + const jwk = generateRsaJwk(); + writeSigningKeys(tempRoot.current, [jwk]); + process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + expect(() => legacyResolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( + "failed to read signing keys: ", + ); + }); + + it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W30n6): this function's own `authEnabled` recompute + // (see its doc comment) used to be ungated, so a malformed override the remote block should + // have made irrelevant aborted the anon/service_role asymmetric-signing path outright. + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect(() => + legacyResolveConfiguredSigningKeys( + config, + tempRoot.current, + undefined, + new Set(["auth.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { + process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + const config = baseConfig({ auth: { enabled: false } }); + expect(() => legacyResolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( + 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + ); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.ts index 37bdfe0314..88f55c094f 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -54,6 +54,14 @@ export interface LegacyLocalProjectContext { export const legacyLoadLocalProjectContext = ( workdir: string, mapConfigLoadError: (message: string) => E, + // The resolved `--linked` ref, when the caller already has one in scope (`db diff`/`db + // pull`'s shadow-provisioning prelude — CLI-1956) — threaded straight into + // `loadProjectConfig`'s own `projectRef` option so the matching `[remotes.]` block + // merges over the base config, exactly like `legacyReadDbToml(..., ref)` already does for + // those same commands' OTHER config read. `db start`/`db reset` never pass this (neither + // operates against a linked target), so it defaults to `undefined` — no remote merge, + // unchanged from before. + projectRef?: string, ): Effect.Effect => Effect.gen(function* () { // `search: false`: `workdir` already IS Go's fully-resolved chdir target (`legacy-cli-config. @@ -166,14 +174,28 @@ export const legacyLoadLocalProjectContext = ( // `config.toml`. tomlOnly: true, goViperCompat: true, + projectRef, }).pipe( Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), ); const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); const hostname = legacyGetHostname(); + // `loaded?.appliedRemote !== undefined` means a `[remotes.]` block matched + // `projectRef` above and `loadProjectConfig` merged it over the base document + // (`packages/config/src/io.ts`'s `applyRemoteOverride`) — including that block's OWN + // `project_id` field, which is what selected it (`config.project_id` already equals + // `projectRef`). Go's `mergeRemoteConfig` installs that value at viper's override tier, + // above `AutomaticEnv` (`apps/cli-go/pkg/config/config.go:718-724`), so a stale/ + // differently-scoped `SUPABASE_PROJECT_ID` must not win over it here either — otherwise + // this context's `projectId` (network id, container labels — same field + // `legacy-db-config.toml-read.ts`'s own `project_id` gating protects for the pg-delta + // context) resolves the WRONG id for a linked `db diff --linked`/`db pull` shadow + // (review: PRRT_kwDOErm0O86XHGDL). const projectId = legacySanitizeProjectId( legacyResolveLocalProjectId( - projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"], + loaded?.appliedRemote !== undefined + ? undefined + : (projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"]), config.project_id, workdir, ), diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts index 814408140d..f4b428da01 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts @@ -31,17 +31,67 @@ function writeDotEnv(workdir: string, contents: string): void { writeFileSync(join(workdir, ".env"), contents); } +function writeConfigToml(workdir: string, contents: string): void { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), contents); +} + const tempRoot = useLegacyTempWorkdir("supabase-legacy-project-context-"); describe("legacyLoadLocalProjectContext", () => { const previousDockerHost = process.env[DOCKER_HOST_KEY]; const previousBitbucketCloneDir = process.env[BITBUCKET_CLONE_DIR_KEY]; + const previousProjectId = process.env["SUPABASE_PROJECT_ID"]; afterEach(() => { if (previousDockerHost === undefined) delete process.env[DOCKER_HOST_KEY]; else process.env[DOCKER_HOST_KEY] = previousDockerHost; if (previousBitbucketCloneDir === undefined) delete process.env[BITBUCKET_CLONE_DIR_KEY]; else process.env[BITBUCKET_CLONE_DIR_KEY] = previousBitbucketCloneDir; + if (previousProjectId === undefined) delete process.env["SUPABASE_PROJECT_ID"]; + else process.env["SUPABASE_PROJECT_ID"] = previousProjectId; + }); + + it.effect( + "prefers a matched [remotes.]'s project_id over a conflicting SUPABASE_PROJECT_ID", + () => { + // Regression (review: PRRT_kwDOErm0O86XHGDL) — `loadProjectConfig`'s own remote merge + // (`packages/config/src/io.ts`) already installs the matched block's `project_id` at + // Go's viper override tier before this reads it; letting an unrelated + // `SUPABASE_PROJECT_ID` win here would resolve the WRONG project id for the shadow's + // own network id/container labels on a linked `db diff`/`db pull`. + process.env["SUPABASE_PROJECT_ID"] = "local"; + const ref = "abcdefghijklmnopqrst"; + const workdir = tempRoot.current; + writeConfigToml( + workdir, + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), + ); + + return legacyLoadLocalProjectContext(workdir, (message) => new Error(message), ref).pipe( + Effect.map((context) => { + expect(context.loaded?.appliedRemote).toBe("prod"); + expect(context.projectId).toBe(ref); + }), + Effect.provide(BunServices.layer), + ); + }, + ); + + it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { + process.env["SUPABASE_PROJECT_ID"] = "env-project"; + const ref = "abcdefghijklmnopqrst"; + const workdir = tempRoot.current; + writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + + return legacyLoadLocalProjectContext(workdir, (message) => new Error(message), ref).pipe( + Effect.map((context) => { + expect(context.loaded?.appliedRemote).toBeUndefined(); + expect(context.projectId).toBe("env-project"); + }), + Effect.provide(BunServices.layer), + ); }); it.effect( diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts index d9f292dd25..11ad8f66fe 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts @@ -1,12 +1,24 @@ import { createHash } from "node:crypto"; import { Clock, Effect, type FileSystem, Option, type Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; -import type { LegacyBaselineTomlConfig } from "./legacy-db-config.toml-read.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import type { LegacyBaselineTomlConfig, LegacyDbTomlValues } from "./legacy-db-config.toml-read.ts"; import { legacyResolveDbImage } from "./legacy-db-image.ts"; +import { legacyBuildLocalDbContainerInputs } from "./db-bootstrap/local-container-inputs.ts"; +import { + legacyCreateShadowDatabase, + legacyRemoveShadowDatabase, +} from "./db-bootstrap/shadow-database.ts"; import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts"; import { type LegacyPgDeltaContext, legacyExportCatalogPgDelta } from "./legacy-pgdelta.ts"; -import { LegacyDeclarativeSeam } from "../commands/db/shared/legacy-pgdelta.seam.service.ts"; +import { + legacyCompareUtf8Bytes, + legacyPrepareShadowSource, + legacyShadowRunInputFromLocalContainerInputs, +} from "../commands/db/shared/legacy-shadow-source.ts"; /** * Declarative catalog-cache key builders + on-disk catalog resolution, ported @@ -18,10 +30,12 @@ import { LegacyDeclarativeSeam } from "../commands/db/shared/legacy-pgdelta.seam * Beyond the pure key/path builders, this file also owns the migrations-catalog * RESOLUTION path for both `db diff --from/--to migrations` and `db schema * declarative sync` ({@link legacyResolveMigrationsCatalogRef}, - * {@link legacyGetMigrationsCatalogRef}) — including shadow-database provisioning/ - * removal via `LegacyDeclarativeSeam` (Docker orchestration, unchanged from the Go - * seam) and the "Creating shadow database..." stderr side effect the latter prints - * on a cache miss. It is not a pure module. + * {@link legacyGetMigrationsCatalogRef}) — including NATIVE shadow-database + * provisioning/removal (CLI-1956, {@link exportViaShadowCatalog}, the same + * `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`/ + * `legacyRemoveShadowDatabase` primitives `db diff`/`db pull` use for their own + * shadow — no seam/subprocess involved) and the "Creating shadow database..." + * stderr side effect the latter prints on a cache miss. It is not a pure module. */ const CATALOG_PREFIX_PATTERN = /[^a-zA-Z0-9._-]+/g; @@ -195,9 +209,11 @@ export function legacyPgDeltaTempPath(path: Path.Path, workdir: string): string /** * Lists local migration file paths under `migrationsDir`. Mirrors Go's - * `migration.ListLocalMigrations` (`pkg/migration/list.go:33`): entries are - * sorted by name, directories skipped, a deprecated `<14-digit>_init.sql` first - * migration (pre-2021-12-09) is skipped, and names must match `_*.sql`. + * `migration.ListLocalMigrations` (`pkg/migration/list.go:33`): entries are sorted by name — Go's + * `fs.ReadDir` byte-wise UTF-8 order, via {@link legacyCompareUtf8Bytes}, not JS's default + * UTF-16-code-unit `Array.prototype.sort()` — directories skipped, a deprecated + * `<14-digit>_init.sql` first migration (pre-2021-12-09) is skipped, and names must match + * `_*.sql`. * * Each skipped file emits a byte-exact stderr warning matching Go's * `fmt.Fprintf(os.Stderr, …)` (`list.go:45-53`) — same wording for both the @@ -228,12 +244,32 @@ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( ), ); if (names.length === 0) return [] as ReadonlyArray; - const sorted = [...names].sort(); + // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) returns entries sorted byte-wise over each + // name's UTF-8 encoding — NOT JS's default `Array.prototype.sort()`, which compares UTF-16 code + // units and disagrees with byte/codepoint order for a supplementary-plane filename character + // alongside a BMP private-use one (see {@link legacyCompareUtf8Bytes}'s own doc comment, + // verified empirically there against both Go's `sort.Strings` and `os.ReadDir`). Left + // uncorrected, such a migrations directory would replay in a different order than Go, and a + // dependent migration could fail or produce a different shadow schema (review: + // PRRT_kwDOErm0O86W3OyD). + const sorted = [...names].sort(legacyCompareUtf8Bytes); const result: Array = []; for (let index = 0; index < sorted.length; index++) { const name = sorted[index]!; - const stat = yield* fs.stat(path.join(migrationsDir, name)).pipe(Effect.option); - if (Option.isSome(stat) && stat.value.type === "Directory") continue; + const entryPath = path.join(migrationsDir, name); + // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a + // directory entry from its own type without following symlinks (verified empirically: + // `DirEntry.IsDir()` reports `false` for a `.sql` symlink whose target is a directory) — + // so a symlinked migration is never skipped as a directory in Go, only later, when + // `ApplyMigrations` fails to read it as a regular file. `fs.stat` below follows + // symlinks, so it would misclassify a symlink-to-directory as a plain directory and + // silently skip it here instead. Check `readLink` (which only succeeds for a symlink) + // first and skip the directory check entirely for symlinks, matching Go's `IsDir()`. + const isSymlink = Option.isSome(yield* fs.readLink(entryPath).pipe(Effect.option)); + if (!isSymlink) { + const stat = yield* fs.stat(entryPath).pipe(Effect.option); + if (Option.isSome(stat) && stat.value.type === "Directory") continue; + } if (index === 0) { const init = INIT_SCHEMA_PATTERN.exec(name); if (init !== null && Number(init[1]) < INIT_SCHEMA_CUTOFF) { @@ -251,7 +287,7 @@ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( ); continue; } - result.push(path.join(migrationsDir, name)); + result.push(entryPath); } return result as ReadonlyArray; }); @@ -551,50 +587,105 @@ export const legacyTryCacheMigrationsCatalog = Effect.fnUntraced(function* ( /** * Shared shadow-provision → pg-delta export → persist → cleanup mechanics behind * both {@link legacyResolveMigrationsCatalogRef} and {@link legacyGetMigrationsCatalogRef} - * on a cache miss: provisions the shadow via the EXISTING - * `LegacyDeclarativeSeam.provisionShadow` (Go's `db __shadow --mode diff`, unchanged - * / out of scope for CLI-1959 — `CreateShadowDatabase` + `MigrateShadowDatabase` are - * the exact same Go primitives both callers' Go counterparts call directly, - * `internal/db/diff/shadow.go:37-53` with `targetLocal=false` skipping its only - * extra branch), exports its catalog via the already-native - * {@link legacyExportCatalogPgDelta} (the same edge-runtime script Go's own - * `ExportCatalogPgDelta` runs), hands the snapshot to `persist` to decide where it - * lands on disk, then ALWAYS removes the shadow container (`Effect.ensuring`, - * success or failure) before returning. The persisted path is made relative to - * `ctx.cwd` before returning: every caller feeds this ref into pg-delta's - * edge-runtime scripts as SOURCE/TARGET, which prefix a bare (non-postgres://) ref - * with `/workspace/` — matching the container bind `${ctx.cwd}:/workspace` - * (`legacyPgDeltaContainerRef`, `legacy-pgdelta.ts:100-103`). Go's equivalent - * (`pgcache.WriteMigrationCatalogSnapshot`) is only ever built from `utils.TempDir`, - * a workdir-RELATIVE constant (Go chdirs into the workdir first), so the ref it - * returns is relative too; return the same shape here rather than the absolute host - * path `persist` builds internally. The two public functions differ only in their - * cache-decision and `persist`'s cache-write logic, not in this mechanics. + * on a cache miss. Provisions the shadow via the SAME native primitives `db + * diff`/`db pull` use for their own diff-source shadow (CLI-1956, + * `legacyCreateShadowDatabase` + `legacyPrepareShadowSource` + + * `legacyRemoveShadowDatabase`, `commands/db/shared/legacy-shadow-source.ts`) — + * NOT the retired `db __shadow` hidden CLI subcommand, which was only ever a + * TS-facing IPC shim over these same Go functions. This is in fact TRUER Go + * parity than the shim it replaces: Go's own two callers of this mechanics — + * `resolveMigrationsCatalogRef` (`apps/cli-go/internal/db/diff/explicit.go:88-126`) + * and `getMigrationsCatalogRef`'s `createShadow`/`createShadowContainer` + * (`apps/cli-go/internal/db/declarative/declarative.go:368-430,487-506`) — both + * call `diff.CreateShadowDatabase` + `diff.MigrateShadowDatabase` (via + * `start.WaitForHealthyService`) DIRECTLY, in-process, never through a CLI + * subcommand. `legacyPrepareShadowSource` is called with `targetLocal: false` + + * `usePgDelta: false`, which skips its ENTIRE declarative-schema-override branch + * (Go's local-target `PrepareShadowSource`/`shadow.go:37-91` branch) — neither Go + * function above ever takes that branch either, since neither has a "target" at + * all; they only ever provision + migrate + export. + * + * Exports the shadow's catalog via the already-native {@link legacyExportCatalogPgDelta} + * (the same edge-runtime script Go's own `ExportCatalogPgDelta` runs), hands the + * snapshot to `persist` to decide where it lands on disk, then ALWAYS removes the + * shadow (`Effect.acquireUseRelease`'s release phase, success or failure) — + * matching Go's `defer utils.DockerRemove(shadow)` immediately after creation, and + * `diff.handler.ts`/`pull.handler.ts`'s own `acquire`=create/`use`=prepare+diff/ + * `release`=remove shape for the exact same interruptibility reason (see + * `legacyPrepareShadowSource`'s own doc comment: creation runs inside + * `acquireUseRelease`'s uninterruptible `acquire`, while the health-wait/migrate + * sequence stays in the interruptible `use` phase, so a SIGINT during either can + * still land while the shadow is still reliably torn down). + * + * The persisted path is made relative to `ctx.cwd` before returning: every caller + * feeds this ref into pg-delta's edge-runtime scripts as SOURCE/TARGET, which + * prefix a bare (non-postgres://) ref with `/workspace/` — matching the container + * bind `${ctx.cwd}:/workspace` (`legacyPgDeltaContainerRef`, `legacy-pgdelta.ts: + * 100-103`). Go's equivalent (`pgcache.WriteMigrationCatalogSnapshot`) is only + * ever built from `utils.TempDir`, a workdir-RELATIVE constant (Go chdirs into the + * workdir first), so the ref it returns is relative too; return the same shape + * here rather than the absolute host path `persist` builds internally. The two + * public functions differ only in their cache-decision and `persist`'s + * cache-write logic, not in this mechanics. + * + * `toml` is the caller's own already-loaded/remote-merged `config.toml` read + * (`legacyReadDbToml`'s result) — used, together with a freshly-built + * {@link legacyBuildLocalDbContainerInputs}, to derive the shadow's own container + * spec (image, JWT secret, root key, `db.settings`, service enabled-for-setup + * flags) exactly like `db diff`/`db pull` do for their own shadow. `projectRef` + * (when set) is threaded into that build so a linked ref's `[remotes.]` + * override reaches this shadow too, matching Go's uniform remote-merge on the + * linked path. */ const exportViaShadowCatalog = ( + fs: FileSystem.FileSystem, path: Path.Path, ctx: LegacyPgDeltaContext, + toml: LegacyDbTomlValues, provisionParams: { readonly projectRef?: string }, persist: (snapshot: string) => Effect.Effect, ) => Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - const shadow = yield* seam.provisionShadow({ - mode: "diff", + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + const debug = yield* LegacyDebugFlag; + const localInputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + ctx.cwd, + networkIdFlag, + runtimeInfo.platform, + debug, + provisionParams.projectRef, + toml.remoteOverrideKeys, + ); + const resolvedImage = yield* localInputs.resolvePostgresImage; + const shadowInput = { + ...legacyShadowRunInputFromLocalContainerInputs(localInputs, resolvedImage, toml, fs, path), targetLocal: false, usePgDelta: false, - schema: [], - ...(provisionParams.projectRef !== undefined - ? { projectRef: provisionParams.projectRef } - : {}), - }); - const written = yield* Effect.gen(function* () { - const snapshot = yield* legacyExportCatalogPgDelta(ctx, { - targetRef: shadow.sourceUrl, - role: "postgres", - }); - return yield* persist(snapshot); - }).pipe(Effect.ensuring(seam.removeShadowContainer(shadow.container))); + schemaPaths: toml.schemaPathPatterns, + pgDelta: toml.pgDelta, + ctx, + }; + const written = yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowInput), + (handle) => + Effect.gen(function* () { + const shadow = yield* legacyPrepareShadowSource(spawner, handle, shadowInput); + const snapshot = yield* legacyExportCatalogPgDelta(ctx, { + targetRef: shadow.sourceUrl, + role: "postgres", + }); + return yield* persist(snapshot); + }), + (handle) => + legacyRemoveShadowDatabase(spawner, { + containerId: handle.containerId, + secretDirId: handle.secretDirId, + workdir: ctx.cwd, + }), + ); return path.relative(ctx.cwd, written); }); @@ -616,12 +707,16 @@ const exportViaShadowCatalog = ( * On a cache miss, the shadow-provision/export/persist/cleanup mechanics are * shared with {@link legacyGetMigrationsCatalogRef} via {@link exportViaShadowCatalog} * — see its doc comment. The catalog is cached with - * {@link legacyWriteMigrationCatalogSnapshot}. + * {@link legacyWriteMigrationCatalogSnapshot}. `toml` is the caller's own + * already-loaded/remote-merged `config.toml` read, threaded through to + * {@link exportViaShadowCatalog} for the shadow's own container spec (CLI-1956) — + * see that function's doc comment. */ export const legacyResolveMigrationsCatalogRef = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, ctx: LegacyPgDeltaContext, + toml: LegacyDbTomlValues, params: { readonly projectRef?: string }, ) { const tempDir = legacyPgDeltaTempPath(path, ctx.cwd); @@ -630,7 +725,7 @@ export const legacyResolveMigrationsCatalogRef = Effect.fnUntraced(function* ( const cached = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, hash, "local"); if (Option.isSome(cached)) return path.relative(ctx.cwd, cached.value); - return yield* exportViaShadowCatalog(path, ctx, params, (snapshot) => + return yield* exportViaShadowCatalog(fs, path, ctx, toml, params, (snapshot) => Effect.gen(function* () { const timestamp = yield* Clock.currentTimeMillis; return yield* legacyWriteMigrationCatalogSnapshot( @@ -665,12 +760,16 @@ const NO_CACHE_MIGRATIONS_CATALOG_NAME = "catalog-nocache-migrations.json"; * * On a cache miss, the shadow-provision/export/persist/cleanup mechanics are * shared with {@link legacyResolveMigrationsCatalogRef} via - * {@link exportViaShadowCatalog} — see its doc comment. + * {@link exportViaShadowCatalog} — see its doc comment. `toml` is the caller's + * own already-loaded/remote-merged `config.toml` read, threaded through for the + * shadow's own container spec (CLI-1956) — distinct from `setupInputs`, which is + * only the cache-key/baseline-setup subset. */ export const legacyGetMigrationsCatalogRef = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, ctx: LegacyPgDeltaContext, + toml: LegacyDbTomlValues, setupInputs: LegacySetupInputs, params: { readonly noCache: boolean; readonly projectRef?: string }, ) { @@ -702,7 +801,7 @@ export const legacyGetMigrationsCatalogRef = Effect.fnUntraced(function* ( } yield* output.raw("Creating shadow database...\n", "stderr"); - return yield* exportViaShadowCatalog(path, ctx, params, (snapshot) => + return yield* exportViaShadowCatalog(fs, path, ctx, toml, params, (snapshot) => Effect.gen(function* () { if (params.noCache) { yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts index fc32436de8..f0ed5460cd 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -199,6 +199,66 @@ describe("legacyListLocalMigrations", () => { }, ); + it.effect( + "includes a validly-named .sql symlink to a directory, matching Go's IsDir() (no follow)", + () => { + // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a + // directory entry from its own type without following symlinks, so a `.sql` symlink + // whose target is a directory is NOT skipped as a directory — it is only ever dropped + // later, if something actually tries to read it as a file. A naive `fs.stat`-based + // directory check (which follows symlinks) would misclassify it and silently skip it. + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + const targetDir = join(dir, "outside-target"); + mkdirSync(targetDir, { recursive: true }); + writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); + symlinkSync(targetDir, join(migrationsDir, "20240102000000_link.sql")); + return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( + Effect.tap((paths) => + Effect.sync(() => { + expect(paths.map((p) => p.split("/").pop())).toEqual([ + "20240101120000_create.sql", + "20240102000000_link.sql", + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + + it.effect( + "sorts by UTF-8 byte order, matching Go's fs.ReadDir, not JS's default UTF-16 code-unit order", + () => { + // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) sorts entries byte-wise over each name's + // UTF-8 encoding. A BMP private-use character (U+E000, single UTF-16 code unit `0xE000`) + // and a supplementary-plane character (U+1F600, a surrogate pair starting `0xD83D`) reverse + // order between the two schemes: JS's default `Array.prototype.sort()` ranks the surrogate + // pair first (`0xD83D < 0xE000`), while Go's byte order — which preserves codepoint order — + // ranks U+1F600 (`> U+FFFF`) after U+E000. A migrations directory with such filenames must + // replay in Go's order, not JS's default, or a dependent migration could apply out of order. + const dir = withTemp(); + const migrationsDir = join(dir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + const privateUseFile = "20240101120000_z\uE000.sql"; + const supplementaryFile = "20240101120000_z\u{1F600}.sql"; + writeFileSync(join(migrationsDir, privateUseFile), "create table x();"); + writeFileSync(join(migrationsDir, supplementaryFile), "create table y();"); + return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( + Effect.tap((paths) => + Effect.sync(() => { + expect(paths.map((p) => p.split("/").pop())).toEqual([ + privateUseFile, + supplementaryFile, + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }, + ); + it.effect("returns [] when the migrations dir is absent", () => { const dir = withTemp(); return withServices((fs, path) => legacyListLocalMigrations(fs, path, join(dir, "nope"))).pipe( diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts index b1f28744aa..7ea24a8cab 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts @@ -25,6 +25,7 @@ const CTX: LegacyPgDeltaContext = { cwd: "/proj", npmVersion: undefined, denoVersion: 2, + projectEnv: {}, }; function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: string } = {}) { diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.ts index d181e4deb0..3b302e34b5 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.ts @@ -1,5 +1,6 @@ import { Effect, FileSystem, Path } from "effect"; +import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; import { type LegacyEdgeRuntimeFile, LegacyEdgeRuntimeScript, @@ -84,6 +85,13 @@ export interface LegacyPgDeltaContext { * config the command operates on rather than the base `config.toml`. */ readonly denoVersion: number; + /** + * The project's parsed `supabase/.env` (`legacyReadDbToml`'s `projectEnv`), so + * {@link legacyPgDeltaNpmRegistryOption}'s `PGDELTA_NPM_REGISTRY` read matches Go's + * `os.Getenv`, which already observes `.env`-loaded values by this point (see that + * function's doc comment). + */ + readonly projectEnv: Readonly>; } /** Mirrors Go's `isPostgresURL` (`internal/db/diff/pgdelta.go:46`). */ @@ -127,13 +135,26 @@ export function legacyIsPgDeltaDebugEnabled(): boolean { * Mirrors Go's `PgDeltaNpmRegistryOption` (`internal/utils/pgdelta_local.go:30`): * when `PGDELTA_NPM_REGISTRY` is set, drop a project-local `.npmrc` scoping the * `@supabase` registry and forward both `PGDELTA_NPM_REGISTRY` and the universal - * `NPM_CONFIG_REGISTRY` into the container. + * `NPM_CONFIG_REGISTRY` into the container. Exported so `legacy-pgdelta.apply.ts`'s + * declarative-apply runner (CLI-1956) can reuse the same option, matching every other + * pg-delta edge-runtime invocation in this file. + * + * `PGDELTA_NPM_REGISTRY` is a bare `os.Getenv` read in Go (`pgdelta_local.go:30`), not a + * viper-bound flag — but by the time Go reaches it, `config.Load`'s `loadNestedEnv` has + * already run `godotenv.Load` on the project's `supabase/.env`, which calls `os.Setenv` for + * every key not already present in the real process env (`godotenv@v1.5.1/godotenv.go:184- + * 200`). So a project `.env`-only `PGDELTA_NPM_REGISTRY` is visible to this exact `os.Getenv` + * call in Go. `projectEnv` reproduces that merge with the same shell-presence-wins semantics + * (review: PRRT_kwDOErm0O86XFmjf). */ -function legacyPgDeltaNpmRegistryOption(): { +export function legacyPgDeltaNpmRegistryOption(projectEnv: Readonly>): { readonly extraFiles?: ReadonlyArray; readonly extraEnv?: Readonly>; } { - const registry = (process.env[PG_DELTA_NPM_REGISTRY_ENV] ?? "").trim(); + const registry = legacyViperEnvStringWithProjectFallback( + PG_DELTA_NPM_REGISTRY_ENV, + projectEnv, + ).trim(); if (registry.length === 0) return {}; return { extraFiles: [{ name: ".npmrc", content: `@supabase:registry=${registry}\n` }], @@ -201,7 +222,7 @@ export const legacyDiffPgDelta = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(); + const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaDiffScript, ctx.npmVersion), @@ -255,7 +276,7 @@ export const legacyDeclarativeExportPgDelta = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(); + const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeExportScript, ctx.npmVersion), @@ -304,7 +325,7 @@ export const legacyExportCatalogPgDelta = Effect.fnUntraced(function* ( const env: Record = {}; yield* appendRefEnv(fs, path, ctx.cwd, env, "TARGET", params.targetRef); if (params.role.length > 0) env["ROLE"] = params.role; - const npm = legacyPgDeltaNpmRegistryOption(); + const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaCatalogExportScript, ctx.npmVersion), diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts index 0ea876c5ad..f012934ce2 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts @@ -6,6 +6,7 @@ import { legacyIsPostgresURL, legacyPgDeltaBinds, legacyPgDeltaContainerRef, + legacyPgDeltaNpmRegistryOption, } from "./legacy-pgdelta.ts"; describe("legacyIsPostgresURL", () => { @@ -74,3 +75,43 @@ describe("legacyIsPgDeltaDebugEnabled", () => { expect(legacyIsPgDeltaDebugEnabled()).toBe(false); }); }); + +describe("legacyPgDeltaNpmRegistryOption", () => { + const prev = process.env["PGDELTA_NPM_REGISTRY"]; + afterEach(() => { + if (prev === undefined) delete process.env["PGDELTA_NPM_REGISTRY"]; + else process.env["PGDELTA_NPM_REGISTRY"] = prev; + }); + + it("returns no option when unset in both the shell and the project .env", () => { + delete process.env["PGDELTA_NPM_REGISTRY"]; + expect(legacyPgDeltaNpmRegistryOption({})).toEqual({}); + }); + + it("falls back to the project .env when the shell env is unset (Go's godotenv.Load parity)", () => { + delete process.env["PGDELTA_NPM_REGISTRY"]; + const npm = legacyPgDeltaNpmRegistryOption({ + PGDELTA_NPM_REGISTRY: "https://registry.example.com", + }); + expect(npm.extraFiles).toEqual([ + { name: ".npmrc", content: "@supabase:registry=https://registry.example.com\n" }, + ]); + expect(npm.extraEnv).toEqual({ + PGDELTA_NPM_REGISTRY: "https://registry.example.com", + NPM_CONFIG_REGISTRY: "https://registry.example.com", + }); + }); + + it("prefers the shell env over the project .env (shell presence wins)", () => { + process.env["PGDELTA_NPM_REGISTRY"] = "https://shell.example.com"; + const npm = legacyPgDeltaNpmRegistryOption({ + PGDELTA_NPM_REGISTRY: "https://dotenv.example.com", + }); + expect(npm.extraEnv?.["PGDELTA_NPM_REGISTRY"]).toBe("https://shell.example.com"); + }); + + it("treats a whitespace-only value as unset", () => { + delete process.env["PGDELTA_NPM_REGISTRY"]; + expect(legacyPgDeltaNpmRegistryOption({ PGDELTA_NPM_REGISTRY: " " })).toEqual({}); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts index 90dbec8e0c..be0ebe1e8b 100644 --- a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts +++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts @@ -1,5 +1,5 @@ import { rm } from "node:fs/promises"; -import { join } from "node:path"; +import { resolve, sep } from "node:path"; import { Effect } from "effect"; @@ -25,17 +25,21 @@ import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts"; * (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer` — a `docker run * -d`, not `docker create`+`docker start`, which bind-mounts its env-file/ * multiline-env-script/serve-main-template artifacts rather than copying - * their content in) — this function has no way to distinguish which - * producer staged a given container's directory, nor does it need to: a - * directory that was never staged in the first place is a harmless no-op (see - * below). + * their content in) and for the shadow database's own staged pgsodium root + * key (`db-bootstrap/shadow-database.ts`'s `legacyCreateShadowDatabase`, + * CLI-1956) — this function has no way to distinguish which producer staged + * a given container's directory, nor does it need to: a directory that was + * never staged in the first place is a harmless no-op (see below). * * Hoisted here (`legacy/shared/`) per `apps/cli/CLAUDE.md`'s "Hoist Before * You Duplicate" rule: both `start`'s own rollback (`legacy/shared/db-bootstrap/rollback.ts`) and * `stop` (`stop.handler.ts`) need this same cleanup. * * Each container's own directory is resolved as `/supabase/.temp/ - * start-secrets/`, where `workdir` is that container's own + * start-secrets/`, where `dirId` is `container.secretDirId` when present + * (an unnamed container's own `LEGACY_CLI_SECRET_DIR_LABEL` value — see that + * constant's doc comment for why `container.name` can't be used for one of + * those) and `container.name` otherwise, and `workdir` is that container's own * `LEGACY_CLI_WORKDIR_LABEL` value (see that constant's doc comment) — NOT * necessarily `fallbackWorkdir` (the caller's own `LegacyCliConfig.workdir`). * A caller tearing down containers by an explicit `--project-id`/`--all` @@ -60,8 +64,22 @@ import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts"; * --project-id`/rollback isn't tearing down. * * Never fails: a directory that was never staged (every service besides Edge - * Runtime) is a harmless no-op, and a real deletion error is not worth - * failing `stop`/rollback over. + * Runtime and the shadow database) is a harmless no-op, and a real deletion + * error is not worth failing `stop`/rollback over. + * + * `dirId` is a Docker label value read back off whatever containers matched + * the caller's label filter (`legacyListContainerIdsAndNames`) — external + * metadata, not something this function generated itself, so it cannot be + * trusted as a bare path segment. A container that matches the project-label + * filter (any container can carry that label; Docker doesn't scope who may + * set it) but carries a crafted `LEGACY_CLI_SECRET_DIR_LABEL` value containing + * `..` segments must never be able to walk the subsequent `rm -rf` outside + * `start-secrets/` and onto arbitrary host paths. Resolve the candidate and + * require it to be a direct child of the staging root before deleting it — + * same defence-in-depth shape as `bootstrap.templates.ts`'s identical guard + * against a GitHub-supplied path escaping its target directory. This also + * covers the degenerate case where `dirId` ends up empty (would otherwise + * resolve to the staging root itself and wipe every project's secrets). */ export function legacyCleanupStartSecrets( containers: ReadonlyArray, @@ -71,7 +89,13 @@ export function legacyCleanupStartSecrets( Promise.all( containers.map((container) => { const workdir = container.workdir.length > 0 ? container.workdir : fallbackWorkdir; - return rm(join(workdir, "supabase", ".temp", "start-secrets", container.name), { + const dirId = container.secretDirId.length > 0 ? container.secretDirId : container.name; + const stagingRoot = resolve(workdir, "supabase", ".temp", "start-secrets"); + const target = resolve(stagingRoot, dirId); + if (target === stagingRoot || !target.startsWith(stagingRoot + sep)) { + return Promise.resolve(); + } + return rm(target, { recursive: true, force: true, }); diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts new file mode 100644 index 0000000000..da7f858bd3 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts @@ -0,0 +1,159 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; + +import { legacyCleanupStartSecrets } from "./legacy-start-secrets-cleanup.ts"; + +describe("legacyCleanupStartSecrets", () => { + it.effect( + "removes a NAMED container's secret directory keyed off container.name when secretDirId is empty", + () => { + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "supabase_kong_demo"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_kong_demo", workdir: "", secretDirId: "" }], + workdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "removes an UNNAMED (shadow) container's secret directory keyed off secretDirId, not container.name", + () => { + // The shadow database is created with no name (Docker auto-generates one) and stages + // its secrets under a randomized `shadow-` id it stamps onto + // `LEGACY_CLI_SECRET_DIR_LABEL` at creation time — `container.name` here is Docker's + // own auto-generated string, which bears no relation to that directory, so cleanup + // must prefer `secretDirId` whenever it's present (review: PRRT_kwDOErm0O86W8ZYt). + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + const secretDirId = "shadow-11111111-1111-1111-1111-111111111111"; + const secretDir = join(workdir, "supabase", ".temp", "start-secrets", secretDirId); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "pgsodium-root-key"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "sad_turing", workdir: "", secretDirId }], + workdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + // The auto-generated Docker name must never be treated as a directory to remove — + // asserting its absence would be vacuous (it was never created), so this only + // documents intent alongside the positive assertion above. + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "falls back to fallbackWorkdir when a container carries no com.supabase.cli.workdir label", + () => { + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "supabase_kong_demo"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_kong_demo", workdir: "", secretDirId: "" }], + workdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("prefers a container's OWN com.supabase.cli.workdir label over fallbackWorkdir", () => { + const ownWorkdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-own-")); + const otherWorkdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-other-")); + const secretDir = join(ownWorkdir, "supabase", ".temp", "start-secrets", "supabase_db_demo"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "db-secret"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_db_demo", workdir: ownWorkdir, secretDirId: "" }], + otherWorkdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + rmSync(ownWorkdir, { recursive: true, force: true }); + rmSync(otherWorkdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect("never fails when nothing was ever staged for a container", () => { + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + return Effect.gen(function* () { + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_realtime_demo", workdir: "", secretDirId: "" }], + workdir, + ); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect( + "refuses to delete outside the staging root when secretDirId contains path-traversal segments", + () => { + // `secretDirId` is a Docker label value read back off whatever containers matched the + // caller's project-label filter — external metadata, not something this process + // generated. Any container carrying that label (Docker doesn't scope who may set it) plus + // a crafted `LEGACY_CLI_SECRET_DIR_LABEL` containing `..` segments must never be able to + // walk `rm -rf` outside `start-secrets/` and onto an unrelated host directory. + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + const canary = join(workdir, "important"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(canary, { recursive: true }); + yield* fs.writeFileString(path.join(canary, "do-not-delete"), "canary"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "sad_turing", workdir: "", secretDirId: "../../important" }], + workdir, + ); + expect(yield* fs.exists(canary)).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect( + "refuses to delete the whole staging root when both secretDirId and name are empty", + () => { + // Degenerate case: an empty `dirId` would otherwise resolve to the staging root itself + // (`/supabase/.temp/start-secrets`) and wipe every project's staged secrets in + // one call, not just this one container's. + const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); + const stagingRoot = join(workdir, "supabase", ".temp", "start-secrets"); + const otherProjectSecretDir = join(stagingRoot, "supabase_kong_other"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(otherProjectSecretDir, { recursive: true }); + yield* fs.writeFileString(path.join(otherProjectSecretDir, "secret-0"), "kong-secret"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "", workdir: "", secretDirId: "" }], + workdir, + ); + expect(yield* fs.exists(otherProjectSecretDir)).toBe(true); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); +}); diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index 03f74e840e..d8508a5923 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -338,3 +338,81 @@ export const legacyResolveExperimentalWithProjectEnv = (projectEnv: Record` occurrence in argv resolves to a pflag `false` + * (`PFLAG_FALSE_VALUES`, matching `ParseBool`'s false set). pflag's `Value.Set` runs for every + * occurrence in argv order, so the last one wins: `--debug=false --debug=true` (or a trailing + * bare `--debug`) is `true` to Go/pflag, not `false` — the Effect parser itself resolves repeats + * first-wins instead (binary-verified precedent for this exact pflag-vs-Effect divergence: + * `apps/cli/src/legacy/commands/sso/sso.pflag-reconcile.ts:306-321`). `--debug` is bound to + * viper the same way as `--yes`/`--experimental` (`apps/cli-go/cmd/root.go:318-334`). + * {@link legacyYesFlagExplicitlyFalse}/{@link legacyExperimentalFlagExplicitlyFalse} above have + * the identical `Array.some` "any occurrence is false" gap (review: PRRT_kwDOErm0O86XKYiG) — + * left as-is here as a pre-existing, cross-cutting fix spanning those two flags too, not folded + * into this port (same scoping precedent as this file's own {@link legacyResolveDebug} doc + * comment for existing `LegacyDebugFlag` call sites). + */ +const legacyDebugFlagExplicitlyFalse = (args: ReadonlyArray): boolean => { + let lastExplicitlyFalse = false; + for (const arg of args) { + if (arg === "--debug") { + lastExplicitlyFalse = false; + } else if (arg.startsWith("--debug=")) { + lastExplicitlyFalse = PFLAG_FALSE_VALUES.has(arg.slice("--debug=".length)); + } + } + return lastExplicitlyFalse; +}; + +/** + * `--debug` resolved with Go's viper `AutomaticEnv` fallback: EVERY Go debug read goes + * through `viper.GetBool("DEBUG")` — never the bare pflag — across the whole Go CLI + * (`apps/cli-go/cmd/root.go:122,289`, `internal/utils/{connect,docker,edgeruntime,logger}.go`, + * `internal/pgdelta/apply.go:332,342`, …), so `SUPABASE_DEBUG` enables debug output exactly + * like `--debug`. An explicit `--debug` — including `--debug=false` — wins over the env, + * matching viper's bound-pflag precedence. Mirrors {@link legacyResolveYes}/ + * {@link legacyResolveExperimental} above. Prefer this over reading {@link LegacyDebugFlag} + * directly for any NEW debug-gated behavior that has a direct, single-call-site Go + * counterpart reading `viper.GetBool("DEBUG")` (review: PRRT_kwDOErm0O86XDr4V) — existing + * `LegacyDebugFlag` call sites predate this helper and are a separate, broader cross-cutting + * cleanup, not folded in here. + */ +export const legacyResolveDebug = Effect.gen(function* () { + const flag = yield* LegacyDebugFlag; + const cliArgs = yield* CliArgs; + if (legacyDebugFlagExplicitlyFalse(cliArgs.args)) { + return false; + } + return flag || legacyViperEnvBool("SUPABASE_DEBUG"); +}); + +/** + * `--debug` resolved with the project `.env` consulted too, for NEW debug-gated behavior that + * runs downstream of a command that has already loaded the nested project env (e.g. + * `legacyApplyDeclarativePgDelta`, reached by `db diff`/`db pull` after `ParseDatabaseConfig`). + * Go's `Config.Load` -> `loadNestedEnv` calls `godotenv.Load`, which `os.Setenv`s every project + * `.env` key not already present in the shell env (`godotenv@v1.5.1/godotenv.go:184-200`) — a + * REAL process-wide mutation that persists for the rest of that Go process, so a later + * `viper.GetBool("DEBUG")` (e.g. `pgdelta.ApplyDeclarative`, `apply.go:332,342`) sees a + * `SUPABASE_DEBUG` set only in `supabase/.env`. This port's own `legacyLoadProjectEnv` is + * deliberately pure (no `process.env` side effect, see its doc comment), so callers that need + * that same env-file value for a `viper.GetBool`-shaped read must pass the loaded map through + * explicitly instead — same shape as {@link legacyResolveYesWithProjectEnv}/ + * {@link legacyResolveExperimentalWithProjectEnv} above (review: PRRT_kwDOErm0O86XL_oz). + * Shell *presence* — any value, including `false`, empty, or garbage — suppresses the file + * value entirely; an explicit `--debug` — including `--debug=false` — wins over both, matching + * viper's bound-pflag precedence. `projectEnv` is the loaded map from `legacyLoadProjectEnv` + * (or `legacyReadDbToml`'s re-export of it). Existing bare {@link LegacyDebugFlag}/ + * {@link legacyResolveDebug} call sites are unaffected — this is additive, for call sites that + * opt in. + */ +export const legacyResolveDebugWithProjectEnv = (projectEnv: Record) => + Effect.gen(function* () { + const flag = yield* LegacyDebugFlag; + const cliArgs = yield* CliArgs; + if (legacyDebugFlagExplicitlyFalse(cliArgs.args)) { + return false; + } + return flag || legacyViperEnvBoolWithProjectFallback("SUPABASE_DEBUG", projectEnv); + }); diff --git a/apps/cli/src/shared/legacy/global-flags.unit.test.ts b/apps/cli/src/shared/legacy/global-flags.unit.test.ts index ec54f34982..561ef61c8e 100644 --- a/apps/cli/src/shared/legacy/global-flags.unit.test.ts +++ b/apps/cli/src/shared/legacy/global-flags.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; +import { CliArgs } from "../cli/cli-args.service.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyAgentFlag, @@ -13,6 +14,7 @@ import { LegacyWorkdirFlag, LegacyYesFlag, legacyGlobalFlagValues, + legacyResolveDebug, } from "./global-flags.ts"; describe("legacyGlobalFlagValues", () => { @@ -81,3 +83,72 @@ describe("legacyGlobalFlagValues", () => { ); }); }); + +describe("legacyResolveDebug", () => { + // Regression (review: PRRT_kwDOErm0O86XKYiG): the raw-argv "explicitly false" scan used to be + // an `Array.some` over every `--debug=` occurrence, so ANY earlier `--debug=false` forced + // the result to `false` even when a LATER occurrence in the same invocation explicitly turned it + // back on. pflag's `Value.Set` runs for every occurrence in argv order — the LAST one wins, not + // "any occurrence is false" — matching the same pflag-vs-Effect-parser divergence already + // binary-verified for `--skip-url-validation` + // (`apps/cli/src/legacy/commands/sso/sso.pflag-reconcile.ts:306-321`). + it.effect("resolves false for a single explicit --debug=false, overriding the flag", () => + Effect.gen(function* () { + const result = yield* legacyResolveDebug; + expect(result).toBe(false); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: ["--debug=false"] }), + ), + ), + ), + ); + + it.effect( + "resolves true for a repeated --debug where the LAST occurrence is =true, not forced false by an earlier =false", + () => + Effect.gen(function* () { + const result = yield* legacyResolveDebug; + expect(result).toBe(true); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: ["--debug=false", "--debug=true"] }), + ), + ), + ), + ); + + it.effect( + "resolves true for --debug=false followed by a trailing bare --debug (last occurrence wins)", + () => + Effect.gen(function* () { + const result = yield* legacyResolveDebug; + expect(result).toBe(true); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: ["--debug=false", "--debug"] }), + ), + ), + ), + ); + + it.effect("is unaffected by an unrelated flag containing the same substring", () => + Effect.gen(function* () { + const result = yield* legacyResolveDebug; + expect(result).toBe(true); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(LegacyDebugFlag, true), + Layer.succeed(CliArgs, { args: ["--some-other-debug-flag=false"] }), + ), + ), + ), + ); +}); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 311ec8f621..3e47545d79 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -4,8 +4,9 @@ import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { type ApiClient, makeApiClient, type SupabaseApiConfigError } from "@supabase/api/effect"; -import { Effect, FileSystem, Layer, Option, Redacted } from "effect"; +import { Effect, FileSystem, Layer, Option, Redacted, Sink, Stream } from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -722,6 +723,101 @@ export function legacyFailWriteStringOnNthCallFsLayer( ).pipe(Layer.provide(BunServices.layer)); } +// --------------------------------------------------------------------------- +// Shadow-database container-CLI spawner — shared by `db diff`/`db pull`'s native +// shadow-provisioning integration tests (CLI-1956). Hoisted here (it was a verbatim +// ~55-line duplicate in both `diff.integration.test.ts` and `pull.integration.test.ts`) +// per `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. +// --------------------------------------------------------------------------- + +/** The shadow container's fake id — used both as `docker create`'s stdout and the `dbHost` `.slice(0, 12)` derives from. */ +export const LEGACY_FAKE_SHADOW_CONTAINER_ID = "abc123456789shadow0".padEnd(64, "0").slice(0, 64); + +/** Go's `container.HealthConfig`-shaped inspect JSON for a healthy container. */ +const LEGACY_SHADOW_HEALTHY_STATE = + '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; + +/** + * A real (Docker-valid) "still starting" state — NOT `Effect.never` — so + * {@link legacyWaitForHealthyServices}'s retry loop genuinely retries on its real 1-second + * `Schedule.spaced` backoff instead of hanging on a single probe forever. Mirrors + * `start.integration.test.ts`'s own "never healthy" containers (same rationale: a fiber + * interrupted mid-retry must be observed actually suspended inside the retry loop, not merely + * past the initial `create` call). + */ +const LEGACY_SHADOW_STARTING_STATE = + '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; + +/** + * Fakes every `docker`/`podman` subprocess call the native shadow-provisioning path issues + * (`legacyBuildLocalDbContainerInputs`'s image-cache check, `legacyCreateShadowDatabase`'s + * network-create + container create/start, `legacyWaitForHealthyServices`'s container + * inspect, and `legacyRemoveShadowDatabase`'s cleanup) — scoped-down port of + * `start.integration.test.ts`'s own `mockContainerCliSpawner`, since both callers only ever + * create one (shadow) container, never named. + * + * `neverHealthy` (default `false`) makes every `container inspect` report `"starting"` instead + * of `"healthy"` — for the interrupt-during-health-wait regression coverage (review: + * 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. + */ +export function mockLegacyShadowContainerCliSpawner( + opts: { readonly neverHealthy?: boolean } = {}, +): { + readonly layer: Layer.Layer; + readonly spawned: ReadonlyArray<{ readonly args: ReadonlyArray }>; +} { + const neverHealthy = opts.neverHealthy ?? false; + const spawned: Array<{ readonly args: ReadonlyArray }> = []; + const encoder = new TextEncoder(); + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push({ args }); + if (command._tag !== "StandardCommand") { + return yield* Effect.fail( + new PlatformError( + new SystemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ), + ); + } + let stdoutLines: ReadonlyArray = []; + if (args[0] === "create") { + stdoutLines = [LEGACY_FAKE_SHADOW_CONTAINER_ID]; + } else if (args[0] === "container" && args[1] === "inspect") { + stdoutLines = [neverHealthy ? LEGACY_SHADOW_STARTING_STATE : LEGACY_SHADOW_HEALTHY_STATE]; + } + // "image inspect", "network create", "start", "rm -f -v" all succeed with no output. + const stdoutBytes = stdoutLines.map((line) => encoder.encode(`${line}\n`)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(7000 + spawned.length), + stdout: Stream.fromIterable(stdoutBytes), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + + return { layer, spawned }; +} + // --------------------------------------------------------------------------- // Runtime composition — bundles the entire Layer.mergeAll(...) graph that // every native-port integration test re-builds, including the easy-to-mis-wire