diff --git a/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-lang-go-when-using-project-id/interactions.json b/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-lang-go-when-using-project-id/interactions.json deleted file mode 100644 index b090bbeaed..0000000000 --- a/apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-lang-go-when-using-project-id/interactions.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "request": { - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null - }, - "response": { - "status": 400, - "headers": { - "content-type": "application/json; charset=utf-8", - "x-gotrue-id": "__UUID__", - "x-ratelimit-limit": "120", - "x-ratelimit-remaining": "119", - "x-ratelimit-reset": "60" - }, - "body": { - "message": "Resource has been removed" - } - } - } -] diff --git a/apps/cli-e2e/src/tests/gen.e2e.test.ts b/apps/cli-e2e/src/tests/gen.e2e.test.ts index cdb173382c..aa92480a80 100644 --- a/apps/cli-e2e/src/tests/gen.e2e.test.ts +++ b/apps/cli-e2e/src/tests/gen.e2e.test.ts @@ -67,12 +67,6 @@ describe("gen", () => { expect(result.stderr).toContain("Project not found"); }); - testBehaviour("exits non-zero with --lang go when using --project-id", async ({ run }) => { - const result = await run(["gen", "types", "--project-id", PROJECT_REF, "--lang", "go"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("db-url"); - }); - testBehaviour("exits non-zero with no data source specified", async ({ runNoProjectId }) => { const result = await runNoProjectId(["gen", "types"]); expect(result.exitCode).not.toBe(0); diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index fbfc293fa2..7344528e08 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -5,7 +5,7 @@ | Path | Format | When | | ----------------------------------------- | ---------- | ---------------------------------------------------------------------------------------- | | `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | -| `/supabase/config.toml` | TOML | when `--local` (required) or `--db-url` (best-effort) is specified | +| `/supabase/config.toml` | TOML | when selecting schemas from config; required for `--local`, best-effort otherwise | | `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | | `/supabase/.temp/pgmeta-version` | plain text | `--local` only — overrides the pg-meta docker image tag | @@ -21,33 +21,46 @@ passed via `docker run --env KEY=VALUE` arguments, mirroring Go's ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------- | -| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | - -Called only for `--linked`, `--project-id`, and the implicit linked-project -fallback. `--local` and `--db-url` do not call the Management API. +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------- | ------------ | ---------------------- | ------------------------------------------ | +| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | (presence only; `404` ⇒ branch ref) | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` | + +The TypeScript endpoint is called for `--linked`, `--project-id`, and the implicit +linked-project fallback when `--lang=typescript`. For other languages on those +project-ref paths, the project endpoint is probed first: a `404` means the ref is a +preview branch (any 404 body), so the branch endpoint supplies the branch database +host/port and credentials for pg-meta. Otherwise the database connection is resolved +for the ref and the login-role endpoint supplies temporary credentials for pg-meta. +On an IPv4-only network where the direct database host is unreachable, an explicit +`--project-id` ref additionally fetches the primary pooler config for that ref to +build an IPv4 connection (the saved workdir `.temp/pooler-url` is ignored because the +ref may differ from the linked workdir). +`--local` and `--db-url` do not call the Management API. ## Subprocesses -| Command | When | Purpose | -| ----------------------------------------------------------------------------- | --------------------- | -------------------------------------------------- | -| `docker container inspect supabase_db_` | `--local` | assert `supabase start` is running | -| `docker run --rm --network --env … node dist/server/server.js` | `--local`, `--db-url` | run pg-meta to generate types from a live database | +| Command | When | Purpose | +| ----------------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------- | +| `docker container inspect supabase_db_` | `--local` | assert `supabase start` is running | +| `docker run --rm --network --env … node dist/server/server.js` | `--local`, `--db-url`, project-ref paths with non-TypeScript `--lang` | run pg-meta to generate types from a live database | A raw TCP `SSLRequest` probe is also opened to the target database host/port to detect TLS support before launching pg-meta (mirrors Go's `isRequireSSL`). ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | -| `SUPABASE_DB_PASSWORD` | local database password for `--local` | no (defaults to `postgres`) | -| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub) | no (defaults to the ECR registry) | -| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | +| Variable | Purpose | Required? | +| ---------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_DB_PASSWORD` | database password for `--local` and the `--linked` workdir project | no (defaults to `postgres`; **ignored** for ad-hoc `--project-id`, which always mints a temporary login role) | +| `SUPABASE_SERVICES_HOSTNAME` | host used for the local TLS probe | no (defaults to `127.0.0.1`) | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | pg-meta image registry override (`docker.io` → Docker Hub) | no (defaults to the ECR registry) | +| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | ## Exit Codes @@ -79,8 +92,9 @@ Not applicable. ## Notes - Exactly one of `--local`, `--linked`, `--project-id`, or `--db-url` must be specified. -- `--lang` flag accepts `typescript` (default), `go`, `swift`, or `python`. Non-typescript - languages require a direct database connection (`--local` or `--db-url`). +- `--lang` flag accepts `typescript` (default), `go`, `swift`, or `python`. Project-ref + paths use the Management API for TypeScript, and use a project database host + + temporary login role + pg-meta for other languages. - `--schema` / `-s` accepts a comma-separated list of schemas to include. - `--swift-access-control` accepts `internal` (default) or `public`. - `--postgrest-v9-compat` generates types compatible with PostgREST v9 and below (requires `--db-url`). diff --git a/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts new file mode 100644 index 0000000000..9d4dbabe58 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts @@ -0,0 +1,377 @@ +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { + makeTempHome, + makeTempStackProject, + runSupabase, +} from "../../../../../tests/helpers/cli.ts"; +import { localDbContainerId, localNetworkId } from "../../../shared/legacy-docker-ids.ts"; + +const TYPEGEN_LANGS = ["typescript", "go", "swift", "python"] as const; +type TypegenLang = (typeof TYPEGEN_LANGS)[number]; + +const LOCAL_POSTGRES_IMAGE = "public.ecr.aws/supabase/postgres:17.6.1.136"; +const LOCAL_POSTGRES_TIMEOUT_MS = 120_000; +const TYPEGEN_TIMEOUT_MS = 90_000; +const REMOTE_E2E_FLAG = "SUPABASE_TYPEGEN_E2E_REMOTE"; +const REMOTE_PROJECT_REF_ENV = "SUPABASE_TEST_PROJECT_REF"; +const OUTPUT_TAIL_LENGTH = 4_000; + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +function tokenlessEnv(profilePath: string) { + return { + SUPABASE_ACCESS_TOKEN: "", + SUPABASE_PROFILE: profilePath, + }; +} + +async function writeOfflineProfile(projectDir: string): Promise { + const profilePath = join(projectDir, "offline-profile.yaml"); + await writeFile( + profilePath, + [ + "name: cli-typegen-e2e", + 'api_url: "http://127.0.0.1:1"', + 'dashboard_url: "http://127.0.0.1:1/dashboard"', + 'docs_url: "http://127.0.0.1:1/docs"', + 'project_host: "example.invalid"', + 'pooler_host: ""', + "", + ].join("\n"), + ); + return profilePath; +} + +async function writeLocalConfig(projectDir: string, projectId: string, dbPort: number) { + const supabaseDir = join(projectDir, "supabase"); + await mkdir(supabaseDir, { recursive: true }); + await writeFile( + join(supabaseDir, "config.toml"), + [ + `project_id = "${projectId}"`, + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${dbPort}`, + "major_version = 17", + "", + ].join("\n"), + ); +} + +function combinedOutput(result: { stdout: string; stderr: string }) { + return `${result.stdout}\n${result.stderr}`; +} + +function expectSucceeded( + command: string, + result: { stdout: string; stderr: string; exitCode: number }, +) { + expect(result.exitCode, `${command}\n${combinedOutput(result)}`).toBe(0); +} + +function outputTail(output: string) { + return output.length > OUTPUT_TAIL_LENGTH + ? output.slice(output.length - OUTPUT_TAIL_LENGTH) + : output; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function runCommand( + command: string, + args: ReadonlyArray, + options: { readonly timeoutMs?: number } = {}, +): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + let timedOut = false; + const timer = + options.timeoutMs === undefined + ? undefined + : setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, options.timeoutMs); + + child.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + child.once("error", (error) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve({ stdout, stderr: `${stderr}${String(error)}`, exitCode: 1 }); + }); + child.once("close", (code) => { + if (settled) return; + settled = true; + if (timer !== undefined) clearTimeout(timer); + resolve({ + stdout, + stderr: timedOut ? `${stderr}\nTimed out after ${options.timeoutMs}ms` : stderr, + exitCode: code ?? 1, + }); + }); + }); +} + +function runDocker(args: ReadonlyArray, options?: { readonly timeoutMs?: number }) { + return runCommand("docker", args, options); +} + +async function expectDockerSucceeded(args: ReadonlyArray, timeoutMs?: number) { + const result = await runDocker(args, { timeoutMs }); + expectSucceeded(`docker ${args.join(" ")}`, result); + return result; +} + +async function waitForLocalPostgres(containerName: string) { + const startedAt = Date.now(); + let lastResult: CommandResult = { stdout: "", stderr: "", exitCode: 1 }; + let consecutiveReadyChecks = 0; + while (Date.now() - startedAt < LOCAL_POSTGRES_TIMEOUT_MS) { + lastResult = await runDocker( + [ + "exec", + "-e", + "PGPASSWORD=postgres", + containerName, + "psql", + "-U", + "postgres", + "-d", + "postgres", + "-tAc", + "select 1", + ], + { timeoutMs: 5_000 }, + ); + if (lastResult.exitCode === 0 && lastResult.stdout.trim() === "1") { + consecutiveReadyChecks += 1; + } else { + consecutiveReadyChecks = 0; + } + if (consecutiveReadyChecks >= 2) { + return; + } + await sleep(1_000); + } + + const logs = await runDocker(["logs", containerName], { timeoutMs: 10_000 }); + throw new Error( + [ + `Timed out waiting for ${containerName}`, + outputTail(combinedOutput(lastResult)), + outputTail(combinedOutput(logs)), + ].join("\n"), + ); +} + +async function startLocalPostgres(input: { readonly projectId: string; readonly dbPort: number }) { + const containerName = localDbContainerId(input.projectId); + const networkName = localNetworkId(input.projectId); + + await expectDockerSucceeded(["network", "create", networkName], 30_000); + await expectDockerSucceeded( + [ + "run", + "--detach", + "--rm", + "--name", + containerName, + "--network", + networkName, + "--network-alias", + "db", + "-p", + `${input.dbPort}:5432`, + "-e", + "POSTGRES_PASSWORD=postgres", + LOCAL_POSTGRES_IMAGE, + "postgres", + "-D", + "/etc/postgresql", + "-c", + "wal_level=logical", + "-c", + "max_wal_senders=5", + "-c", + "max_replication_slots=5", + ], + LOCAL_POSTGRES_TIMEOUT_MS, + ); + await waitForLocalPostgres(containerName); + + return { containerName, networkName }; +} + +async function seedSmokeTable(containerName: string) { + await expectDockerSucceeded( + [ + "exec", + "-e", + "PGPASSWORD=postgres", + containerName, + "psql", + "-U", + "postgres", + "-d", + "postgres", + "-v", + "ON_ERROR_STOP=1", + "-c", + [ + "create table if not exists public.typegen_smoke (", + "id bigint generated by default as identity primary key,", + "name text not null,", + "is_active boolean not null default true,", + "created_at timestamptz not null default now()", + ");", + ].join(" "), + ], + 30_000, + ); +} + +async function cleanupLocalPostgres(input: { + readonly containerName: string; + readonly networkName: string; +}) { + await runDocker(["rm", "-f", input.containerName], { timeoutMs: 30_000 }); + await runDocker(["network", "rm", input.networkName], { timeoutMs: 30_000 }); +} + +function expectNoRemoteAuthPath(result: { stdout: string; stderr: string }) { + const output = combinedOutput(result); + expect(output).not.toContain("Access token not provided"); + expect(output).not.toContain("api.supabase.com"); + expect(output).not.toContain("127.0.0.1:1"); +} + +function expectLanguageShape(lang: TypegenLang, stdout: string) { + expect(stdout.trim().length, `${lang} stdout`).toBeGreaterThan(0); + switch (lang) { + case "typescript": + expect(stdout).toContain("export type Database"); + break; + case "go": + expect(stdout).toMatch(/\btype\b/); + break; + case "swift": + expect(stdout).toMatch(/\bstruct\b/); + break; + case "python": + expect(stdout).toContain("from __future__ import annotations"); + break; + } +} + +function expectLocalSmokeTable(lang: TypegenLang, stdout: string) { + if (lang === "typescript") { + expect(stdout).toContain("typegen_smoke"); + return; + } + expect(stdout).toContain("TypegenSmoke"); +} + +describe("legacy gen types e2e", () => { + test( + "generates all supported languages from a tokenless local stack", + { timeout: LOCAL_POSTGRES_TIMEOUT_MS + TYPEGEN_TIMEOUT_MS * TYPEGEN_LANGS.length }, + async () => { + const home = makeTempHome(); + const project = await makeTempStackProject("supabase-typegen-local-e2e-"); + const projectId = `typegen${project.ports.dbPort}`; + const profilePath = await writeOfflineProfile(project.dir); + const env = tokenlessEnv(profilePath); + const localPostgres = { + containerName: localDbContainerId(projectId), + networkName: localNetworkId(projectId), + }; + + try { + await writeLocalConfig(project.dir, projectId, project.ports.dbPort); + await cleanupLocalPostgres(localPostgres); + await startLocalPostgres({ projectId, dbPort: project.ports.dbPort }); + await seedSmokeTable(localPostgres.containerName); + + for (const lang of TYPEGEN_LANGS) { + const result = await runSupabase( + ["gen", "types", "--local", "--lang", lang, "--schema", "public"], + { + cwd: project.dir, + home: home.dir, + env, + entrypoint: "legacy", + exitTimeoutMs: TYPEGEN_TIMEOUT_MS, + }, + ); + expectSucceeded(`supabase gen types --local --lang ${lang}`, result); + expectNoRemoteAuthPath(result); + expectLanguageShape(lang, result.stdout); + expectLocalSmokeTable(lang, result.stdout); + } + } finally { + await cleanupLocalPostgres(localPostgres); + } + }, + ); + + const remoteProjectRef = process.env[REMOTE_PROJECT_REF_ENV]; + const remoteAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; + const remoteEnabled = process.env[REMOTE_E2E_FLAG] === "1"; + + const remoteTest = remoteEnabled ? test : test.skip; + + remoteTest( + "generates all supported languages from a remote project", + { timeout: TYPEGEN_TIMEOUT_MS * TYPEGEN_LANGS.length }, + async () => { + const home = makeTempHome(); + if ( + remoteProjectRef === undefined || + remoteProjectRef.length === 0 || + remoteAccessToken === undefined || + remoteAccessToken.length === 0 + ) { + throw new Error( + `Set ${REMOTE_E2E_FLAG}=1, ${REMOTE_PROJECT_REF_ENV}, and SUPABASE_ACCESS_TOKEN to run remote typegen e2e.`, + ); + } + + for (const lang of TYPEGEN_LANGS) { + const result = await runSupabase( + ["gen", "types", "--project-id", remoteProjectRef, "--lang", lang, "--schema", "public"], + { + home: home.dir, + env: { SUPABASE_ACCESS_TOKEN: remoteAccessToken }, + entrypoint: "legacy", + exitTimeoutMs: TYPEGEN_TIMEOUT_MS, + }, + ); + expectSucceeded(`supabase gen types --project-id --lang ${lang}`, result); + expectLanguageShape(lang, result.stdout); + } + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index c1e4f7bf32..4dea53b4bd 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -1,7 +1,11 @@ import { loadProjectConfig } from "@supabase/config"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; -import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyNetworkIdFlag, +} from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; @@ -10,6 +14,8 @@ import { PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/legacy-project-ref.service.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { legacyTempPaths } from "../../../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; @@ -18,8 +24,8 @@ import { LegacyGenTypesNetworkError, LegacyGenTypesUnexpectedStatusError } from import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { - buildPostgresUrl, defaultSchemas, + buildPostgresUrl, localDbContainerId, localDbPassword, localNetworkId, @@ -37,6 +43,30 @@ const mapProjectTypesError = mapLegacyHttpError({ statusMessage: (_status, body) => `failed to retrieve generated types: ${body}`, }); +const mapProjectDatabaseHostError = mapLegacyHttpError({ + networkError: LegacyGenTypesNetworkError, + statusError: LegacyGenTypesUnexpectedStatusError, + networkMessage: (cause) => `failed to get project database config: ${cause}`, + statusMessage: (status, body) => `unexpected project database config status ${status}: ${body}`, +}); + +const mapBranchDatabaseConfigError = mapLegacyHttpError({ + networkError: LegacyGenTypesNetworkError, + statusError: LegacyGenTypesUnexpectedStatusError, + networkMessage: (cause) => `failed to get preview branch database config: ${cause}`, + statusMessage: (status, body) => + `unexpected preview branch database config status ${status}: ${body}`, +}); + +// A 404 from `GET /v1/projects/{ref}` means the ref is a preview branch rather +// than a project, so fall back to the branch config endpoint. Mirror the link +// handler, which treats *any* 404 as the branch case +// (`link.handler.ts:46-50` / Go's `checkRemoteProjectStatus`); do not narrow on +// the response body, since the Management API's 404 wording is not guaranteed. +function isProjectNotFound(cause: unknown) { + return cause instanceof LegacyGenTypesUnexpectedStatusError && cause.status === 404; +} + function ensureMutuallyExclusive( group: ReadonlyArray, present: ReadonlyArray, @@ -166,12 +196,14 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const path = yield* Path.Path; const stdio = yield* Stdio.Stdio; const networkId = yield* LegacyNetworkIdFlag; + const dnsResolver = yield* LegacyDnsResolverFlag; const debug = yield* LegacyDebugFlag; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const rawArgs = yield* stdio.args; const platformApi = yield* LegacyPlatformApiFactory; const projectRef = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; + const dbConfig = yield* LegacyDbConfigResolver; yield* ensureMutuallyExclusive( ["local", "linked", "project-id", "db-url"], @@ -182,34 +214,6 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ...(Option.isSome(flags.dbUrl) ? ["db-url"] : []), ], ); - yield* ensureMutuallyExclusive( - ["linked", "project-id", "swift-access-control"], - [ - ...(flags.linked ? ["linked"] : []), - ...(Option.isSome(flags.projectId) ? ["project-id"] : []), - ...(hasExplicitLongFlag(rawArgs, "swift-access-control") ? ["swift-access-control"] : []), - ], - ); - yield* ensureMutuallyExclusive( - ["linked", "project-id", "postgrest-v9-compat"], - [ - ...(flags.linked ? ["linked"] : []), - ...(Option.isSome(flags.projectId) ? ["project-id"] : []), - ...(flags.postgrestV9Compat ? ["postgrest-v9-compat"] : []), - ], - ); - yield* ensureMutuallyExclusive( - ["linked", "project-id", "query-timeout"], - [ - ...(flags.linked ? ["linked"] : []), - ...(Option.isSome(flags.projectId) ? ["project-id"] : []), - ...(hasExplicitLongFlag(rawArgs, "query-timeout") ? ["query-timeout"] : []), - ], - ); - - if (flags.postgrestV9Compat && Option.isNone(flags.dbUrl)) { - return yield* Effect.fail(new Error("--postgrest-v9-compat must used together with --db-url")); - } const legacyLang = findLegacyPositionalLanguage(rawArgs); if ( Option.isSome(legacyLang) && @@ -225,20 +229,71 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const queryTimeoutSeconds = yield* parseQueryTimeoutSeconds(flags.queryTimeout); const lang = flags.lang; const swiftAccessControl = flags.swiftAccessControl; + const usesPgMeta = flags.local || Option.isSome(flags.dbUrl) || flags.lang !== "typescript"; + + if (hasExplicitLongFlag(rawArgs, "swift-access-control") && lang !== "swift") { + return yield* Effect.fail( + new Error("--swift-access-control can only be used with --lang swift"), + ); + } + if (flags.postgrestV9Compat && !usesPgMeta) { + return yield* Effect.fail( + new Error("--postgrest-v9-compat can only be used with pg-meta type generation"), + ); + } + if (hasExplicitLongFlag(rawArgs, "query-timeout") && !usesPgMeta) { + return yield* Effect.fail( + new Error("--query-timeout can only be used with pg-meta type generation"), + ); + } const loadConfig = () => loadProjectConfig(cliConfig.workdir); - const runProjectTypes = (projectRef: string, includedSchemas: ReadonlyArray) => + const runProjectTypes = ( + projectRef: string, + includedSchemas: ReadonlyArray, + // True for an explicit `--project-id ` (an ad-hoc remote project that may + // differ from the current workdir); false for `--linked` / the linked fallback. + adHocProjectRef: boolean, + ) => Effect.gen(function* () { + const api = yield* platformApi.make; + if (lang !== "typescript") { - return yield* Effect.fail( - new Error( - `Unable to generate ${lang} types for selected project. Try using --db-url flag instead.`, + const projectResult = yield* api.v1.getProject({ ref: projectRef }).pipe( + Effect.catch(mapProjectDatabaseHostError), + Effect.as("project" as const), + Effect.catch((cause) => + isProjectNotFound(cause) + ? runPreviewBranchTypes(projectRef, includedSchemas).pipe( + Effect.as("branch" as const), + ) + : Effect.fail(cause), ), ); + if (projectResult === "branch") return; + + const resolved = yield* dbConfig.resolve({ + dbUrl: Option.none(), + connType: "linked", + dnsResolver, + linkedProjectRef: Option.some(projectRef), + adHocProjectRef, + }); + const conn = resolved.conn; + yield* runPgMeta({ + url: legacyToPostgresURL(conn), + host: conn.host, + port: conn.port, + probeHost: conn.host, + probePort: conn.port, + networkMode: "host", + includedSchemas: includedSchemas.join(","), + postgrestV9Compat: flags.postgrestV9Compat, + }); + return; } - const api = yield* platformApi.make; const response = yield* api.v1 .generateTypescriptTypes({ ref: projectRef, @@ -249,6 +304,35 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le yield* output.raw(response.types); }).pipe(Effect.ensuring(linkedProjectCache.cache(projectRef))); + const runPreviewBranchTypes = (branchRef: string, includedSchemas: ReadonlyArray) => + Effect.gen(function* () { + const api = yield* platformApi.make; + const branch = yield* api.v1 + .getABranchConfig({ branch_id_or_ref: branchRef }) + .pipe(Effect.catch(mapBranchDatabaseConfigError)); + + if (branch.db_user === undefined || branch.db_pass === undefined) { + return yield* Effect.fail(new Error("Preview branch database credentials are unavailable")); + } + + yield* runPgMeta({ + url: legacyToPostgresURL({ + host: branch.db_host, + port: branch.db_port, + user: branch.db_user, + password: branch.db_pass, + database: "postgres", + }), + host: branch.db_host, + port: branch.db_port, + probeHost: branch.db_host, + probePort: branch.db_port, + networkMode: "host", + includedSchemas: includedSchemas.join(","), + postgrestV9Compat: flags.postgrestV9Compat, + }); + }); + const runPgMeta = (input: { readonly url: string; readonly host: string; @@ -440,6 +524,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le yield* runProjectTypes( ref, schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas), + false, ); return; } @@ -450,6 +535,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le yield* runProjectTypes( ref, schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas), + true, ); return; } @@ -471,6 +557,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le yield* runProjectTypes( resolvedRef, schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas), + false, ); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index 48016e3ddf..65653a32f6 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -4,12 +4,21 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; +import type { + V1CreateLoginRoleOutput, + V1GetABranchConfigOutput, + V1GetProjectOutput, +} from "@supabase/api/effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { CliOutput, Command } from "effect/unstable/cli"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { Deferred, Effect, Exit, Layer, Option, Sink, Stdio, Stream } from "effect"; import { LEGACY_GLOBAL_FLAGS, LegacyDebugFlag, + LegacyDnsResolverFlag, LegacyNetworkIdFlag, LegacyOutputFlag, } from "../../../../shared/legacy/global-flags.ts"; @@ -36,6 +45,13 @@ import { textCliOutputFormatter } from "../../../../shared/output/text-formatter import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyDbConfigError } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; import { legacyGenCommand } from "../gen.command.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; import { legacyGenTypes } from "./types.handler.ts"; @@ -112,6 +128,66 @@ function defaultFlags(overrides: Partial = {}): LegacyGenTy }; } +function statusApiError(status: number, body: string) { + const request = HttpClientRequest.get("https://api.supabase.test/v1/projects/ref"); + const response = HttpClientResponse.fromWeb( + request, + new Response(body, { + status, + headers: { "content-type": "application/json" }, + }), + ); + return new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); +} + +function remoteResolvedConfig( + conn: LegacyPgConnInput, + ref = LEGACY_VALID_REF, +): LegacyResolvedDbConfig { + return { conn, isLocal: false, ref: Option.some(ref) }; +} + +function mockDbConfigResolver( + opts: { + readonly resolve?: ( + flags: LegacyDbConfigFlags, + ) => Effect.Effect; + } = {}, +) { + const resolves: Array = []; + const poolerFallbacks: Array = []; + const layer = Layer.succeed(LegacyDbConfigResolver, { + resolve: (flags) => + Effect.gen(function* () { + resolves.push(flags); + return yield* ( + opts.resolve?.(flags) ?? + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ) + ); + }), + resolvePoolerFallback: (flags) => + Effect.sync(() => { + poolerFallbacks.push(flags); + return Option.none(); + }), + }); + return { layer, resolves, poolerFallbacks }; +} + +type BranchConfig = typeof V1GetABranchConfigOutput.Type; +type LoginRole = typeof V1CreateLoginRoleOutput.Type; +type Project = typeof V1GetProjectOutput.Type; + function setup( opts: { readonly workdir?: string; @@ -135,6 +211,17 @@ function setup( readonly ref: string; readonly included_schemas?: string; }) => Effect.Effect<{ readonly types: string }, unknown>; + readonly getABranchConfig?: (input: { + readonly branch_id_or_ref: string; + }) => Effect.Effect; + readonly getProject?: (input: { readonly ref: string }) => Effect.Effect; + readonly createLoginRole?: (input: { + readonly ref: string; + readonly read_only: boolean; + }) => Effect.Effect; + readonly dbConfigResolve?: ( + flags: LegacyDbConfigFlags, + ) => Effect.Effect; } = {}, ) { const workdir = opts.workdir ?? mkdtempSync(join(tmpdir(), "supabase-gen-types-")); @@ -147,6 +234,7 @@ function setup( }); const telemetry = mockLegacyTelemetryStateTracked(); const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const dbConfig = mockDbConfigResolver({ resolve: opts.dbConfigResolve }); const processControl = mockProcessControl(); const child = mockChildProcessSpawner({ stdout: [...(opts.childStdout ?? [])], @@ -156,6 +244,48 @@ function setup( }); const api = mockLegacyPlatformApiService({ v1: { + getABranchConfig: + opts.getABranchConfig ?? + (({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: 5432, + db_user: "postgres", + db_pass: "postgres", + jwt_secret: "secret", + })), + getProject: + opts.getProject ?? + (({ ref }) => + Effect.succeed({ + id: ref, + ref, + organization_id: "org-id", + organization_slug: "org", + name: "demo", + region: "us-east-1", + created_at: "2025-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: `db.${ref}.supabase.co`, + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, + })), + createLoginRole: + opts.createLoginRole ?? + (() => + Effect.succeed({ + role: "postgres", + password: "postgres", + ttl_seconds: 3600, + })), generateTypescriptTypes: opts.generateTypescriptTypes ?? (({ included_schemas }) => @@ -184,10 +314,12 @@ function setup( Stdio.layerTest({ args: Effect.succeed(opts.args ?? ["gen", "types"]) }), Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), Layer.succeed(LegacyDebugFlag, opts.debug ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native" as const), Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), Layer.succeed(LegacyPlatformApiFactory, { make: LegacyPlatformApi.pipe(Effect.provide(api.layer)), }), + dbConfig.layer, ); return { @@ -195,6 +327,7 @@ function setup( out, telemetry, linkedProjectCache, + dbConfig, processControl, child, api, @@ -294,6 +427,15 @@ async function withSslProbeServer( } } +const nonTypescriptProjectRefScenarios = [ + { lang: "go", stdout: "type PublicMovies struct {}" }, + { lang: "swift", stdout: "struct PublicMovies: Codable {}" }, + { lang: "python", stdout: "class PublicMovies(BaseModel):" }, +] as const satisfies ReadonlyArray<{ + readonly lang: Exclude; + readonly stdout: string; +}>; + const legacyTestRoot = Command.make("supabase").pipe( Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), Command.withSubcommands([legacyGenCommand]), @@ -533,91 +675,469 @@ describe("legacy gen types", () => { }); }); - it.live("rejects combining --linked with --swift-access-control", () => { + it.live("rejects --swift-access-control for non-Swift generation", () => { const { layer } = setup({ - args: ["gen", "types", "--linked", "--swift-access-control", "public"], + args: ["gen", "types", "--local", "--lang", "python", "--swift-access-control", "public"], }); return Effect.gen(function* () { const exit = yield* legacyGenTypes( - defaultFlags({ linked: true, swiftAccessControl: "public" }), + defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id swift-access-control] are set none of the others can be; [linked swift-access-control] were all set", + "--swift-access-control can only be used with --lang swift", ); } }); }); - it.live("rejects combining --linked with --postgrest-v9-compat", () => { - const { layer } = setup({ args: ["gen", "types", "--linked", "--postgrest-v9-compat"] }); + it.live("rejects --postgrest-v9-compat for remote TypeScript generation", () => { + const { layer } = setup({ + args: ["gen", "types", "--project-id", LEGACY_VALID_REF, "--postgrest-v9-compat"], + }); return Effect.gen(function* () { const exit = yield* legacyGenTypes( - defaultFlags({ linked: true, postgrestV9Compat: true }), + defaultFlags({ projectId: Option.some(LEGACY_VALID_REF), postgrestV9Compat: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id postgrest-v9-compat] are set none of the others can be; [linked postgrest-v9-compat] were all set", + "--postgrest-v9-compat can only be used with pg-meta type generation", ); } }); }); - it.live("rejects combining --linked with --query-timeout", () => { - const { layer } = setup({ args: ["gen", "types", "--linked", "--query-timeout", "20s"] }); - - return Effect.gen(function* () { - const exit = yield* legacyGenTypes(defaultFlags({ linked: true, queryTimeout: "20s" })).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "if any flags in the group [linked project-id query-timeout] are set none of the others can be; [linked query-timeout] were all set", - ); - } + it.live("rejects --query-timeout for remote TypeScript generation", () => { + const { layer } = setup({ + args: ["gen", "types", "--project-id", LEGACY_VALID_REF, "--query-timeout", "20s"], }); - }); - - it.live("requires --db-url when --postgrest-v9-compat is set", () => { - const { layer } = setup({ args: ["gen", "types", "--local", "--postgrest-v9-compat"] }); return Effect.gen(function* () { const exit = yield* legacyGenTypes( - defaultFlags({ local: true, postgrestV9Compat: true }), + defaultFlags({ projectId: Option.some(LEGACY_VALID_REF), queryTimeout: "20s" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --db-url", + "--query-timeout can only be used with pg-meta type generation", ); } }); }); - it.live("rejects non-typescript project generation", () => { - const { layer } = setup({ args: ["gen", "types", "--lang", "go"] }); + it.live("allows --postgrest-v9-compat for local pg-meta generation", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-v9-flag-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + + const { layer } = setup({ + workdir, + args: ["gen", "types", "--local", "--postgrest-v9-compat"], + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes(defaultFlags({ local: true, postgrestV9Compat: true })).pipe( + Effect.provide(layer), + ), + ); + + expect( + docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + for (const scenario of nonTypescriptProjectRefScenarios) { + it.live(`generates ${scenario.lang} types from a project ref through the DB resolver`, () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, out, child, api, linkedProjectCache, dbConfig } = setup({ + args: ["gen", "types", "--lang", scenario.lang, "--project-id", LEGACY_VALID_REF], + childStdout: [scenario.stdout], + dbConfigResolve: (input) => + Effect.succeed( + remoteResolvedConfig( + { + host: "127.0.0.1", + port, + user: `cli_login_${LEGACY_VALID_REF}`, + password: "temporary-password", + database: "postgres", + }, + (input.linkedProjectRef !== undefined + ? Option.getOrUndefined(input.linkedProjectRef) + : undefined) ?? LEGACY_VALID_REF, + ), + ), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.fail(new Error(`unexpected preview branch lookup for ${branch_id_or_ref}`)), + getProject: ({ ref }) => + Effect.succeed({ + id: ref, + ref, + organization_id: "org-id", + organization_slug: "org", + name: "demo", + region: "us-east-1", + created_at: "2025-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: `127.0.0.1:${port}`, + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, + }), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: scenario.lang, + }), + ).pipe(Effect.provide(layer)), + ); + + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: LEGACY_VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "getABranchConfig" }), + ); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "generateTypescriptTypes" }), + ); + expect(child.spawned[0]?.args).toContain("--network"); + expect(child.spawned[0]?.args).toContain("host"); + expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://cli_login_${LEGACY_VALID_REF}:temporary-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + // --project-id is an ad-hoc remote ref: the resolver must not inherit + // the workdir's ambient password / saved pooler URL. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); + const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; + expect( + linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, + ).toBe(LEGACY_VALID_REF); + expect(docker.env.has(`PG_META_GENERATE_TYPES=${scenario.lang}`)).toBe(true); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); + expect(out.stdoutText).toContain(scenario.stdout); + expect(linkedProjectCache.cached).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + } + + it.live("resolves the linked workdir DB without ad-hoc project-ref semantics", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--linked"], + projectId: Option.some(LEGACY_VALID_REF), + childStdout: ["type PublicMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "workdir-password", + database: "postgres", + }), + ), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)), + ); + + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + // --linked is the workdir's own project: keep workdir-scoped credentials. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("preserves resolver URL options for remote non-TypeScript typegen", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childStdout: ["type PublicMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + options: `reference=${LEGACY_VALID_REF}`, + }), + ), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)), + ); + + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10&options=reference%3D${LEGACY_VALID_REF}`, + ), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("allows pg-meta flags for remote non-TypeScript project refs", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer } = setup({ + args: [ + "gen", + "types", + "--lang", + "swift", + "--project-id", + LEGACY_VALID_REF, + "--swift-access-control", + "public", + "--query-timeout", + "20s", + "--postgrest-v9-compat", + ], + childStdout: ["struct PublicMovies: Codable {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "swift", + swiftAccessControl: "public", + queryTimeout: "20s", + postgrestV9Compat: true, + }), + ).pipe(Effect.provide(layer)), + ); + + expect(docker.env.has("PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=public")).toBe(true); + expect(docker.env.has("PG_QUERY_TIMEOUT_SECS=20")).toBe(true); + expect( + docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("falls back to preview branch config for non-TypeScript project refs", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, api, dbConfig } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childStdout: ["class PublicMovies(BaseModel):"], + getProject: () => + Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: port, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)), + ); + + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: LEGACY_VALID_REF }, + }); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: LEGACY_VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(dbConfig.resolves).toHaveLength(0); + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("falls back to preview branch config for any project 404 body", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, api, dbConfig } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childStdout: ["class PublicMovies(BaseModel):"], + // The Management API's 404 wording is not guaranteed; a generic body + // must still route to the branch config endpoint. + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: port, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)), + ); + + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: LEGACY_VALID_REF }, + }); + expect(dbConfig.resolves).toHaveLength(0); + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("fails clearly when preview branch config does not include DB credentials", () => { + const { layer } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + getProject: () => Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: 5432, + jwt_secret: "secret", + }), + }); return Effect.gen(function* () { const exit = yield* legacyGenTypes( defaultFlags({ projectId: Option.some(LEGACY_VALID_REF), - lang: "go", + lang: "python", }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("Try using --db-url flag instead."); + expect(String(exit.cause)).toContain("Preview branch database credentials are unavailable"); } }); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.layers.ts b/apps/cli/src/legacy/commands/gen/types/types.layers.ts index 098328a0d1..81fbd8e1df 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.layers.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.layers.ts @@ -7,6 +7,9 @@ import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { LegacyIdentityStitch, @@ -43,8 +46,16 @@ export const legacyGenTypesRuntimeLayer = (() => { Layer.provide(legacyDebugLoggerLayer), Layer.provide(legacyIdentityStitchLayer), ); + const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), + ); const built = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, cliConfig, platformApiFactory, legacyProjectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliConfig)), @@ -77,6 +88,7 @@ type LegacyGenTypesServices = | LegacyPlatformApiFactory | LegacyCliConfig | LegacyProjectRefResolver + | LegacyDbConfigResolver | LegacyLinkedProjectCache | LegacyTelemetryState | LegacyIdentityStitch diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 564e8f6e11..8b9ced78fc 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -127,7 +127,7 @@ describe("legacy services", () => { CliOutput.layer(textCliOutputFormatter()), out.layer, analytics.layer, - processEnvLayer({ SUPABASE_HOME: workdir }), + processEnvLayer({ SUPABASE_HOME: workdir, SUPABASE_NO_KEYRING: "1" }), mockRuntimeInfo({ cwd: workdir, homeDir: workdir }), mockTty({ stdinIsTty: false, stdoutIsTty: false }), Stdio.layerTest({ args: Effect.succeed(args) }), diff --git a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts index 80e37455bb..effeb1bb35 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -326,6 +326,10 @@ export const legacyDbConfigLayer = Layer.effect( // the resolve-time IPv6 path (`NewDbConfigWithPassword` → `GetPoolerConfig`) uses // the saved URL only and errors otherwise, so this defaults off. fetchFromApi = false, + // For an ad-hoc `--project-id` ref the saved `.temp/pooler-url` belongs to the + // (possibly different) linked workdir, so ignore it and resolve the pooler for + // `ref` from the Management API instead. + ignoreSavedUrl = false, ): Effect.Effect< Option.Option, LegacyDbConfigError, @@ -333,7 +337,9 @@ export const legacyDbConfigLayer = Layer.effect( > => Effect.gen(function* () { const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - let connectionString = Option.getOrUndefined(tomlValues.poolerConnectionString); + let connectionString = ignoreSavedUrl + ? undefined + : Option.getOrUndefined(tomlValues.poolerConnectionString); if (connectionString === undefined) { if (!fetchFromApi) return Option.none(); // No saved pooler URL → fetch the primary pooler config from the Management @@ -368,11 +374,17 @@ export const legacyDbConfigLayer = Layer.effect( ref: string, dnsResolver: "native" | "https", passwordFlag: Option.Option, + adHocProjectRef = false, ): Effect.Effect => Effect.gen(function* () { // Read lazily (per invocation) rather than at layer build, so tests and - // env-substitution see the current value. - const dbPassword = yield* resolveDbPassword(passwordFlag); + // env-substitution see the current value. For an ad-hoc `--project-id` ref, + // honor only an explicit `--password` flag and ignore the ambient + // `SUPABASE_DB_PASSWORD` (which belongs to the current workdir, not this ref), + // so we always mint a temporary login role instead of leaking it. + const dbPassword = adHocProjectRef + ? (Option.getOrUndefined(passwordFlag) ?? "") + : yield* resolveDbPassword(passwordFlag); const host = `db.${ref}.${cliConfig.projectHost}`; const base: LegacyPgConnInput = { host, @@ -391,8 +403,17 @@ export const legacyDbConfigLayer = Layer.effect( return yield* initLoginRole(ref, base); } - // Direct host unreachable (IPv6-only network) → try the pooler. - const poolerConn = yield* resolvePoolerConn(ref, dnsResolver, base.password); + // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc + // `--project-id` ref the command already holds a Management API token, so fall + // back to the API pooler config (and ignore the workdir's saved pooler URL) + // rather than failing with the IPv6 "run supabase link" suggestion. + const poolerConn = yield* resolvePoolerConn( + ref, + dnsResolver, + base.password, + adHocProjectRef, + adHocProjectRef, + ); if (Option.isNone(poolerConn)) { return yield* Effect.fail( new Errors.LegacyDbConfigIpv6Error({ @@ -468,7 +489,7 @@ export const legacyDbConfigLayer = Layer.effect( // workdir fails with ErrNotLinked, a bad ref with the invalid-ref error, and an // unreadable ref file surfaces the filesystem problem — matching Go for every // caller of this resolver (`test db --linked`, dump, declarative). - const ref = yield* projectRef.loadProjectRef(Option.none()); + const ref = yield* projectRef.loadProjectRef(flags.linkedProjectRef ?? Option.none()); // Go's `ParseDatabaseConfig` runs `LoadProjectRef` → `LoadConfig` → // `NewDbConfigWithPassword` (`internal/utils/flags/db_url.go:81-92`), so // the `[remotes.]`-merged config (e.g. an unsupported remote @@ -483,6 +504,7 @@ export const legacyDbConfigLayer = Layer.effect( ref, flags.dnsResolver, flags.password ?? Option.none(), + flags.adHocProjectRef ?? false, ); // NB: the linked-project telemetry cache (GET /v1/projects/{ref}) is NOT // issued here. Go caches it in `PersistentPostRun` @@ -527,7 +549,7 @@ export const legacyDbConfigLayer = Layer.effect( if (flags.connType !== "linked") return Option.none(); return yield* Effect.gen(function* () { const projectRef = yield* LegacyProjectRefResolver; - const refOpt = yield* projectRef.resolveOptional(Option.none()); + const refOpt = yield* projectRef.resolveOptional(flags.linkedProjectRef ?? Option.none()); if (Option.isNone(refOpt)) return Option.none(); const ref = refOpt.value; if (!PROJECT_REF_PATTERN.test(ref)) return Option.none(); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.types.ts b/apps/cli/src/legacy/shared/legacy-db-config.types.ts index 951bc86107..11af4fc5d2 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.types.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.types.ts @@ -32,6 +32,29 @@ export interface LegacyDbConfigFlags { * flag (e.g. `test db`) omit it; the resolver then falls back to env only. */ readonly password?: Option.Option; + /** + * Optional explicit linked project ref override. Commands such as + * `gen types --project-id ` need the linked DB resolver's temp-role and + * pooler fallback behavior without requiring the current workdir to be linked. + * Absent for the normal `--linked` path, which still reads `.temp/project-ref`. + */ + readonly linkedProjectRef?: Option.Option; + /** + * Marks `linkedProjectRef` as an ad-hoc remote target supplied explicitly + * (e.g. `gen types --project-id `) rather than the current linked + * workdir. The ref may belong to a different project than the cwd, so the + * resolver must NOT inherit workdir-scoped credentials or cached state: + * - it ignores the ambient `SUPABASE_DB_PASSWORD` (shell / `.env*`) so it + * always mints a temporary login role instead of handing pg-meta an + * unrelated password, and + * - on an IPv4-only network it skips the saved `.temp/pooler-url` (which + * belongs to the linked workdir) and fetches the primary pooler config + * for `ref` from the Management API instead of failing with the IPv6 + * "run supabase link" suggestion. + * Absent / false for the normal `--linked` path, which is the workdir's own + * project and may legitimately reuse those env vars and saved files. + */ + readonly adHocProjectRef?: boolean; } /**