From 7a633ed220f4dd485687724f179111debce13005 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 18 Jun 2026 16:07:13 +0200 Subject: [PATCH 1/7] fix(cli): generate python types from project ref --- .../legacy/commands/gen/types/SIDE_EFFECTS.md | 28 ++++--- .../commands/gen/types/types.handler.ts | 43 ++++++++-- .../gen/types/types.integration.test.ts | 84 +++++++++++++++---- .../services/services.integration.test.ts | 2 +- .../deploy/deploy.integration.test.ts | 19 +++-- apps/cli/src/shared/functions/deploy.ts | 3 +- 6 files changed, 135 insertions(+), 44 deletions(-) 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..9b96a6ee83 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,19 +21,22 @@ 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 | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | ------------------------------------------ | +| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | +| `GET` | `/v1/branches/{ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | -Called only for `--linked`, `--project-id`, and the implicit linked-project -fallback. `--local` and `--db-url` do not call the Management API. +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 branch config endpoint supplies a direct DB URL for pg-meta. +`--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`). @@ -79,8 +82,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 branch DB config + 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.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index c1e4f7bf32..7c23768fc8 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -37,6 +37,13 @@ const mapProjectTypesError = mapLegacyHttpError({ statusMessage: (_status, body) => `failed to retrieve generated types: ${body}`, }); +const mapProjectDatabaseConfigError = mapLegacyHttpError({ + networkError: LegacyGenTypesNetworkError, + statusError: LegacyGenTypesUnexpectedStatusError, + networkMessage: (cause) => `failed to get project database config: ${cause}`, + statusMessage: (status, body) => `unexpected project database config status ${status}: ${body}`, +}); + function ensureMutuallyExclusive( group: ReadonlyArray, present: ReadonlyArray, @@ -230,15 +237,39 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const runProjectTypes = (projectRef: string, includedSchemas: ReadonlyArray) => 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 detail = yield* api.v1 + .getABranchConfig({ branch_id_or_ref: projectRef }) + .pipe(Effect.catch(mapProjectDatabaseConfigError)); + if (detail.db_pass === undefined || detail.db_pass.length === 0) { + return yield* Effect.fail( + new Error( + `Unable to generate ${lang} types for selected project because the database password is unavailable. Try using --db-url flag instead.`, + ), + ); + } + + yield* runPgMeta({ + url: buildPostgresUrl({ + host: detail.db_host, + port: detail.db_port, + user: detail.db_user ?? "postgres", + password: detail.db_pass, + database: "postgres", + }), + host: detail.db_host, + port: detail.db_port, + probeHost: detail.db_host, + probePort: detail.db_port, + networkMode: "host", + includedSchemas: includedSchemas.join(","), + postgrestV9Compat: flags.postgrestV9Compat, + }); + return; } - const api = yield* platformApi.make; const response = yield* api.v1 .generateTypescriptTypes({ ref: projectRef, 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..7f72955af2 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,6 +4,7 @@ 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 { V1GetABranchConfigOutput } from "@supabase/api/effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { CliOutput, Command } from "effect/unstable/cli"; import { Deferred, Effect, Exit, Layer, Option, Sink, Stdio, Stream } from "effect"; @@ -112,6 +113,8 @@ function defaultFlags(overrides: Partial = {}): LegacyGenTy }; } +type BranchConfig = typeof V1GetABranchConfigOutput.Type; + function setup( opts: { readonly workdir?: string; @@ -135,6 +138,9 @@ 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; } = {}, ) { const workdir = opts.workdir ?? mkdtempSync(join(tmpdir(), "supabase-gen-types-")); @@ -156,6 +162,21 @@ 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", + })), generateTypescriptTypes: opts.generateTypescriptTypes ?? (({ included_schemas }) => @@ -604,23 +625,56 @@ describe("legacy gen types", () => { }); }); - it.live("rejects non-typescript project generation", () => { - const { layer } = setup({ args: ["gen", "types", "--lang", "go"] }); + it.live("generates python types from a project ref", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, out, child, api, linkedProjectCache } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childStdout: ["class PublicMovies(BaseModel):"], + 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: "postgres", + db_pass: "postgres", + jwt_secret: "secret", + }), + onSpawn: docker.onSpawn, + }); - return Effect.gen(function* () { - const exit = yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit); + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)), + ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("Try using --db-url flag instead."); - } - }); - }); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: LEGACY_VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "generateTypescriptTypes" }), + ); + expect(child.spawned[0]?.args).toContain("--network"); + expect(child.spawned[0]?.args).toContain("host"); + expect(docker.env.has("PG_META_GENERATE_TYPES=python")).toBe(true); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); + expect(out.stdoutText).toContain("class PublicMovies(BaseModel):"); + expect(linkedProjectCache.cached).toBe(true); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); it.live("maps project type generation network failures", () => { const { layer } = setup({ 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/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index 3f19ed9573..6bc6055e1f 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, FunctionResponse } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, sep } from "node:path"; import { Effect, Layer, Option, Sink, Stdio, Stream } from "effect"; @@ -343,6 +343,11 @@ function resolveDockerOutputPath(args: ReadonlyArray): string { throw new Error(`unable to resolve host output path for ${dockerOutputPath}`); } +async function dockerBindSpec(hostPath: string, mode: "ro" | "rw") { + const resolved = await realpath(hostPath); + return `${resolved}:${resolved.replaceAll("\\", "/").replace(/^[A-Za-z]:/, "")}:${mode}`; +} + function mockChildProcessSpawner( opts: { readonly exitCode?: number; @@ -1208,13 +1213,9 @@ describe("functions deploy", () => { expect(api.requests[1]?.urlParams).toContain("verify_jwt=false"); expect(child.spawned.at(-1)?.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4"); expect(child.spawned.at(-1)?.args).toContain( - `${join(tempDir, "supabase", "custom_import_map.json")}:${join( - tempDir, - "supabase", - "custom_import_map.json", - ) - .replaceAll("\\", "/") - .replace(/^[A-Za-z]:/, "")}:ro`, + yield* Effect.promise(() => + dockerBindSpec(join(tempDir, "supabase", "custom_import_map.json"), "ro"), + ), ); expect(out.stderrText).toContain("Bundling Function: hello-world\n"); expect(out.stderrText).toContain("Deploying Function: hello-world (script size:"); @@ -1512,7 +1513,7 @@ describe("functions deploy", () => { expect(child.spawned).toHaveLength(4); expect(child.spawned.at(-1)?.args).toContain( - `${staticFile}:${staticFile.replaceAll("\\", "/").replace(/^[A-Za-z]:/, "")}:ro`, + yield* Effect.promise(() => dockerBindSpec(staticFile, "ro")), ); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 1e7e0af0a6..7016fa692d 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -656,7 +656,8 @@ async function walkImportPaths( } const resolvedModule = resolve(modulePath); - if (!isContainedInAnyPath(allowedRoots, resolvedModule)) { + const containmentPath = await realpath(resolvedModule).catch(() => resolvedModule); + if (!isContainedInAnyPath(allowedRoots, containmentPath)) { await onWarning(`WARN: Skipping import path outside project root: ${modulePath}\n`); continue; } From de3b70369e5c0cda2cd8a7d0b8aa871ac5678cb7 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 18 Jun 2026 16:47:45 +0200 Subject: [PATCH 2/7] fix(cli): use login role for project python typegen --- .../legacy/commands/gen/types/SIDE_EFFECTS.md | 16 ++-- .../commands/gen/types/types.handler.ts | 51 +++++++---- .../gen/types/types.integration.test.ts | 88 ++++++++++++++++--- 3 files changed, 117 insertions(+), 38 deletions(-) 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 9b96a6ee83..3350abc6fe 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -21,14 +21,16 @@ 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 | -| `GET` | `/v1/branches/{ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | +| 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 | `database.host` | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` | 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 branch config endpoint supplies a direct DB URL for pg-meta. +project-ref paths, the project endpoint supplies the database host and the login-role +endpoint supplies temporary credentials for pg-meta. `--local` and `--db-url` do not call the Management API. ## Subprocesses @@ -83,8 +85,8 @@ Not applicable. - Exactly one of `--local`, `--linked`, `--project-id`, or `--db-url` must be specified. - `--lang` flag accepts `typescript` (default), `go`, `swift`, or `python`. Project-ref - paths use the Management API for TypeScript, and use branch DB config + pg-meta for - other languages. + 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.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 7c23768fc8..bd4939007e 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -37,13 +37,28 @@ const mapProjectTypesError = mapLegacyHttpError({ statusMessage: (_status, body) => `failed to retrieve generated types: ${body}`, }); -const mapProjectDatabaseConfigError = mapLegacyHttpError({ +const mapProjectLoginRoleError = mapLegacyHttpError({ + networkError: LegacyGenTypesNetworkError, + statusError: LegacyGenTypesUnexpectedStatusError, + networkMessage: (cause) => `failed to initialise login role: ${cause}`, + statusMessage: (status, body) => `unexpected login role status ${status}: ${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}`, }); +function parseProjectDatabaseHost(host: string) { + const parsed = new URL(`postgresql://${host}`); + return { + host: parsed.hostname, + port: parsed.port.length > 0 ? Number.parseInt(parsed.port, 10) : 5432, + }; +} + function ensureMutuallyExclusive( group: ReadonlyArray, present: ReadonlyArray, @@ -240,29 +255,27 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const api = yield* platformApi.make; if (lang !== "typescript") { - const detail = yield* api.v1 - .getABranchConfig({ branch_id_or_ref: projectRef }) - .pipe(Effect.catch(mapProjectDatabaseConfigError)); - if (detail.db_pass === undefined || detail.db_pass.length === 0) { - return yield* Effect.fail( - new Error( - `Unable to generate ${lang} types for selected project because the database password is unavailable. Try using --db-url flag instead.`, - ), - ); - } + const project = yield* api.v1 + .getProject({ ref: projectRef }) + .pipe(Effect.catch(mapProjectDatabaseHostError)); + const target = parseProjectDatabaseHost(project.database.host); + yield* output.raw("Initialising login role...\n", "stderr"); + const role = yield* api.v1 + .createLoginRole({ ref: projectRef, read_only: false }) + .pipe(Effect.catch(mapProjectLoginRoleError)); yield* runPgMeta({ url: buildPostgresUrl({ - host: detail.db_host, - port: detail.db_port, - user: detail.db_user ?? "postgres", - password: detail.db_pass, + host: target.host, + port: target.port, + user: role.role, + password: role.password, database: "postgres", }), - host: detail.db_host, - port: detail.db_port, - probeHost: detail.db_host, - probePort: detail.db_port, + host: target.host, + port: target.port, + probeHost: target.host, + probePort: target.port, networkMode: "host", includedSchemas: includedSchemas.join(","), postgrestV9Compat: flags.postgrestV9Compat, 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 7f72955af2..d7ca111024 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,7 +4,11 @@ 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 { V1GetABranchConfigOutput } from "@supabase/api/effect"; +import type { + V1CreateLoginRoleOutput, + V1GetABranchConfigOutput, + V1GetProjectOutput, +} from "@supabase/api/effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { CliOutput, Command } from "effect/unstable/cli"; import { Deferred, Effect, Exit, Layer, Option, Sink, Stdio, Stream } from "effect"; @@ -114,6 +118,8 @@ function defaultFlags(overrides: Partial = {}): LegacyGenTy } type BranchConfig = typeof V1GetABranchConfigOutput.Type; +type LoginRole = typeof V1CreateLoginRoleOutput.Type; +type Project = typeof V1GetProjectOutput.Type; function setup( opts: { @@ -141,6 +147,11 @@ function setup( 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; } = {}, ) { const workdir = opts.workdir ?? mkdtempSync(join(tmpdir(), "supabase-gen-types-")); @@ -177,6 +188,33 @@ function setup( 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 }) => @@ -634,17 +672,29 @@ describe("legacy gen types", () => { args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], childStdout: ["class PublicMovies(BaseModel):"], getABranchConfig: ({ branch_id_or_ref }) => + Effect.fail(new Error(`unexpected preview branch lookup for ${branch_id_or_ref}`)), + getProject: ({ ref }) => Effect.succeed({ - ref: branch_id_or_ref, - postgres_version: "15.1", - postgres_engine: "15", - release_channel: "ga", + 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", - db_host: "127.0.0.1", - db_port: port, - db_user: "postgres", - db_pass: "postgres", - jwt_secret: "secret", + database: { + host: `127.0.0.1:${port}`, + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, + }), + createLoginRole: ({ ref, read_only }) => + Effect.succeed({ + role: `cli_login_${ref}`, + password: "temporary-password", + ttl_seconds: read_only ? 1800 : 3600, }), onSpawn: docker.onSpawn, }); @@ -659,14 +709,28 @@ describe("legacy gen types", () => { ); expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: LEGACY_VALID_REF }, + method: "getProject", + input: { ref: LEGACY_VALID_REF }, }); + expect(api.requests).toContainEqual({ + method: "createLoginRole", + input: { ref: LEGACY_VALID_REF, read_only: false }, + }); + 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("Initialising login role..."); + 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(docker.env.has("PG_META_GENERATE_TYPES=python")).toBe(true); expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); expect(out.stdoutText).toContain("class PublicMovies(BaseModel):"); From c08200b958b10ca26f74ed86b2ff877e99e4b401 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 18 Jun 2026 17:26:25 +0200 Subject: [PATCH 3/7] test(cli): cover project typegen languages --- .../gen/types/types.integration.test.ts | 159 ++++++++++-------- 1 file changed, 85 insertions(+), 74 deletions(-) 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 d7ca111024..40937f4a30 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 @@ -353,6 +353,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]), @@ -663,82 +672,84 @@ describe("legacy gen types", () => { }); }); - it.live("generates python types from a project ref", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, out, child, api, linkedProjectCache } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], - childStdout: ["class PublicMovies(BaseModel):"], - 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, read_only }) => - Effect.succeed({ - role: `cli_login_${ref}`, - password: "temporary-password", - ttl_seconds: read_only ? 1800 : 3600, - }), - onSpawn: docker.onSpawn, - }); + for (const scenario of nonTypescriptProjectRefScenarios) { + it.live(`generates ${scenario.lang} types from a project ref`, () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, out, child, api, linkedProjectCache } = setup({ + args: ["gen", "types", "--lang", scenario.lang, "--project-id", LEGACY_VALID_REF], + childStdout: [scenario.stdout], + 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, read_only }) => + Effect.succeed({ + role: `cli_login_${ref}`, + password: "temporary-password", + ttl_seconds: read_only ? 1800 : 3600, + }), + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)), - ); + 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).toContainEqual({ - method: "createLoginRole", - input: { ref: LEGACY_VALID_REF, read_only: false }, - }); - 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("Initialising login role..."); - 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(docker.env.has("PG_META_GENERATE_TYPES=python")).toBe(true); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); - expect(out.stdoutText).toContain("class PublicMovies(BaseModel):"); - expect(linkedProjectCache.cached).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: LEGACY_VALID_REF }, + }); + expect(api.requests).toContainEqual({ + method: "createLoginRole", + input: { ref: LEGACY_VALID_REF, read_only: false }, + }); + 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("Initialising login role..."); + 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(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("maps project type generation network failures", () => { const { layer } = setup({ From 075020d27346bc03fdd0567bb78a0be48e9abb2e Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 18 Jun 2026 17:49:58 +0200 Subject: [PATCH 4/7] fix: support project-ref type generation for non-TS langs --- supabase/.temp/linked-project.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 supabase/.temp/linked-project.json diff --git a/supabase/.temp/linked-project.json b/supabase/.temp/linked-project.json new file mode 100644 index 0000000000..2098760851 --- /dev/null +++ b/supabase/.temp/linked-project.json @@ -0,0 +1 @@ +{"ref":"hhrqlmthvbnwvlawqnwi","name":"rere","organization_id":"rnwamzlptflscprylent","organization_slug":"rnwamzlptflscprylent"} \ No newline at end of file From e8f27f854d63743ca3232a212085a9c675e7a9ad Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 18 Jun 2026 19:14:14 +0200 Subject: [PATCH 5/7] test(cli): cover local and remote typegen e2e --- .../commands/gen/types/types.e2e.test.ts | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts 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..fbdc3266a1 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts @@ -0,0 +1,226 @@ +import { readFile, 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"; + +const TYPEGEN_LANGS = ["typescript", "go", "swift", "python"] as const; +type TypegenLang = (typeof TYPEGEN_LANGS)[number]; + +const LOCAL_STACK_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"; + +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 useIsolatedDbPorts( + projectDir: string, + ports: { dbPort: number; shadowPort: number }, +) { + const configPath = join(projectDir, "supabase", "config.toml"); + const config = await readFile(configPath, "utf8"); + await writeFile( + configPath, + config + .replace("port = 54322", `port = ${ports.dbPort}`) + .replace("shadow_port = 54320", `shadow_port = ${ports.shadowPort}`), + ); +} + +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 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_STACK_TIMEOUT_MS + TYPEGEN_TIMEOUT_MS * TYPEGEN_LANGS.length }, + async () => { + const home = makeTempHome(); + const project = await makeTempStackProject("supabase-typegen-local-e2e-"); + const profilePath = await writeOfflineProfile(project.dir); + const env = tokenlessEnv(profilePath); + let initialized = false; + + try { + const init = await runSupabase(["init"], { + cwd: project.dir, + home: home.dir, + env, + entrypoint: "legacy", + }); + expectSucceeded("supabase init", init); + initialized = true; + + await useIsolatedDbPorts(project.dir, { + dbPort: project.ports.dbPort, + shadowPort: project.ports.postgrestAdminPort, + }); + + const start = await runSupabase(["db", "start"], { + cwd: project.dir, + home: home.dir, + env, + entrypoint: "legacy", + exitTimeoutMs: LOCAL_STACK_TIMEOUT_MS, + }); + expectSucceeded("supabase db start", start); + expectNoRemoteAuthPath(start); + + const seed = await runSupabase( + [ + "db", + "query", + [ + "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(" "), + ], + { + cwd: project.dir, + home: home.dir, + env, + entrypoint: "legacy", + }, + ); + expectSucceeded("supabase db query", seed); + expectNoRemoteAuthPath(seed); + + 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 { + if (initialized) { + await runSupabase(["stop", "--no-backup"], { + cwd: project.dir, + home: home.dir, + env, + entrypoint: "legacy", + exitTimeoutMs: 30_000, + }); + } + } + }, + ); + + 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); + } + }, + ); +}); From cfc71434b02459212cf34602e63d822e28f59a04 Mon Sep 17 00:00:00 2001 From: avallete Date: Sat, 20 Jun 2026 11:32:39 +0200 Subject: [PATCH 6/7] test(cli): stabilize typegen e2e coverage --- .../interactions.json | 29 -- apps/cli-e2e/src/tests/gen.e2e.test.ts | 6 - .../commands/gen/types/types.e2e.test.ts | 289 +++++++++++++----- 3 files changed, 220 insertions(+), 104 deletions(-) delete mode 100644 apps/cli-e2e/fixtures/scenarios/gen-types-exits-non-zero-with-lang-go-when-using-project-id/interactions.json 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/types.e2e.test.ts b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts index fbdc3266a1..9d4dbabe58 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts @@ -1,4 +1,5 @@ -import { readFile, writeFile } from "node:fs/promises"; +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 { @@ -6,14 +7,23 @@ import { 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_STACK_TIMEOUT_MS = 120_000; +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 { @@ -39,17 +49,22 @@ async function writeOfflineProfile(projectDir: string): Promise { return profilePath; } -async function useIsolatedDbPorts( - projectDir: string, - ports: { dbPort: number; shadowPort: number }, -) { - const configPath = join(projectDir, "supabase", "config.toml"); - const config = await readFile(configPath, "utf8"); +async function writeLocalConfig(projectDir: string, projectId: string, dbPort: number) { + const supabaseDir = join(projectDir, "supabase"); + await mkdir(supabaseDir, { recursive: true }); await writeFile( - configPath, - config - .replace("port = 54322", `port = ${ports.dbPort}`) - .replace("shadow_port = 54320", `shadow_port = ${ports.shadowPort}`), + join(supabaseDir, "config.toml"), + [ + `project_id = "${projectId}"`, + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${dbPort}`, + "major_version = 17", + "", + ].join("\n"), ); } @@ -64,6 +79,188 @@ function expectSucceeded( 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"); @@ -100,61 +297,23 @@ function expectLocalSmokeTable(lang: TypegenLang, stdout: string) { describe("legacy gen types e2e", () => { test( "generates all supported languages from a tokenless local stack", - { timeout: LOCAL_STACK_TIMEOUT_MS + TYPEGEN_TIMEOUT_MS * TYPEGEN_LANGS.length }, + { 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); - let initialized = false; + const localPostgres = { + containerName: localDbContainerId(projectId), + networkName: localNetworkId(projectId), + }; try { - const init = await runSupabase(["init"], { - cwd: project.dir, - home: home.dir, - env, - entrypoint: "legacy", - }); - expectSucceeded("supabase init", init); - initialized = true; - - await useIsolatedDbPorts(project.dir, { - dbPort: project.ports.dbPort, - shadowPort: project.ports.postgrestAdminPort, - }); - - const start = await runSupabase(["db", "start"], { - cwd: project.dir, - home: home.dir, - env, - entrypoint: "legacy", - exitTimeoutMs: LOCAL_STACK_TIMEOUT_MS, - }); - expectSucceeded("supabase db start", start); - expectNoRemoteAuthPath(start); - - const seed = await runSupabase( - [ - "db", - "query", - [ - "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(" "), - ], - { - cwd: project.dir, - home: home.dir, - env, - entrypoint: "legacy", - }, - ); - expectSucceeded("supabase db query", seed); - expectNoRemoteAuthPath(seed); + 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( @@ -173,15 +332,7 @@ describe("legacy gen types e2e", () => { expectLocalSmokeTable(lang, result.stdout); } } finally { - if (initialized) { - await runSupabase(["stop", "--no-backup"], { - cwd: project.dir, - home: home.dir, - env, - entrypoint: "legacy", - exitTimeoutMs: 30_000, - }); - } + await cleanupLocalPostgres(localPostgres); } }, ); From 864fdd786bf1941e1d7ae3352b2633bafbad536b Mon Sep 17 00:00:00 2001 From: avallete Date: Sat, 20 Jun 2026 14:14:23 +0200 Subject: [PATCH 7/7] fix(cli): route remote typegen through db resolver --- .../commands/gen/types/types.handler.ts | 154 ++++--- .../gen/types/types.integration.test.ts | 386 ++++++++++++++++-- .../legacy/commands/gen/types/types.layers.ts | 12 + .../legacy/shared/legacy-db-config.layer.ts | 4 +- .../legacy/shared/legacy-db-config.types.ts | 7 + supabase/.temp/linked-project.json | 1 - 6 files changed, 458 insertions(+), 106 deletions(-) delete mode 100644 supabase/.temp/linked-project.json 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 bd4939007e..5dd57b2064 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,26 +43,27 @@ const mapProjectTypesError = mapLegacyHttpError({ statusMessage: (_status, body) => `failed to retrieve generated types: ${body}`, }); -const mapProjectLoginRoleError = mapLegacyHttpError({ +const mapProjectDatabaseHostError = mapLegacyHttpError({ networkError: LegacyGenTypesNetworkError, statusError: LegacyGenTypesUnexpectedStatusError, - networkMessage: (cause) => `failed to initialise login role: ${cause}`, - statusMessage: (status, body) => `unexpected login role status ${status}: ${body}`, + networkMessage: (cause) => `failed to get project database config: ${cause}`, + statusMessage: (status, body) => `unexpected project database config status ${status}: ${body}`, }); -const mapProjectDatabaseHostError = mapLegacyHttpError({ +const mapBranchDatabaseConfigError = mapLegacyHttpError({ networkError: LegacyGenTypesNetworkError, statusError: LegacyGenTypesUnexpectedStatusError, - networkMessage: (cause) => `failed to get project database config: ${cause}`, - statusMessage: (status, body) => `unexpected project database config status ${status}: ${body}`, + networkMessage: (cause) => `failed to get preview branch database config: ${cause}`, + statusMessage: (status, body) => + `unexpected preview branch database config status ${status}: ${body}`, }); -function parseProjectDatabaseHost(host: string) { - const parsed = new URL(`postgresql://${host}`); - return { - host: parsed.hostname, - port: parsed.port.length > 0 ? Number.parseInt(parsed.port, 10) : 5432, - }; +function isPreviewBranchNotFound(cause: unknown) { + return ( + cause instanceof LegacyGenTypesUnexpectedStatusError && + cause.status === 404 && + cause.body.includes("Preview branch not found") + ); } function ensureMutuallyExclusive( @@ -188,12 +195,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"], @@ -204,34 +213,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) && @@ -247,6 +228,23 @@ 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); @@ -255,27 +253,32 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const api = yield* platformApi.make; if (lang !== "typescript") { - const project = yield* api.v1 - .getProject({ ref: projectRef }) - .pipe(Effect.catch(mapProjectDatabaseHostError)); - const target = parseProjectDatabaseHost(project.database.host); - yield* output.raw("Initialising login role...\n", "stderr"); - const role = yield* api.v1 - .createLoginRole({ ref: projectRef, read_only: false }) - .pipe(Effect.catch(mapProjectLoginRoleError)); + const projectResult = yield* api.v1.getProject({ ref: projectRef }).pipe( + Effect.catch(mapProjectDatabaseHostError), + Effect.as("project" as const), + Effect.catch((cause) => + isPreviewBranchNotFound(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), + }); + const conn = resolved.conn; yield* runPgMeta({ - url: buildPostgresUrl({ - host: target.host, - port: target.port, - user: role.role, - password: role.password, - database: "postgres", - }), - host: target.host, - port: target.port, - probeHost: target.host, - probePort: target.port, + url: legacyToPostgresURL(conn), + host: conn.host, + port: conn.port, + probeHost: conn.host, + probePort: conn.port, networkMode: "host", includedSchemas: includedSchemas.join(","), postgrestV9Compat: flags.postgrestV9Compat, @@ -293,6 +296,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; 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 40937f4a30..1a01d5dc0f 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 @@ -11,10 +11,14 @@ import type { } 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"; @@ -41,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"; @@ -117,6 +128,62 @@ 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; @@ -152,6 +219,9 @@ function setup( 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-")); @@ -164,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 ?? [])], @@ -243,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 { @@ -254,6 +327,7 @@ function setup( out, telemetry, linkedProjectCache, + dbConfig, processControl, child, api, @@ -601,86 +675,127 @@ 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("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`, () => + 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 } = setup({ + 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 }) => @@ -700,12 +815,8 @@ describe("legacy gen types", () => { release_channel: "ga", }, }), - createLoginRole: ({ ref, read_only }) => - Effect.succeed({ - role: `cli_login_${ref}`, - password: "temporary-password", - ttl_seconds: read_only ? 1800 : 3600, - }), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), onSpawn: docker.onSpawn, }); @@ -722,10 +833,9 @@ describe("legacy gen types", () => { method: "getProject", input: { ref: LEGACY_VALID_REF }, }); - expect(api.requests).toContainEqual({ - method: "createLoginRole", - input: { ref: LEGACY_VALID_REF, read_only: false }, - }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); expect(api.requests).not.toContainEqual( expect.objectContaining({ method: "getABranchConfig" }), ); @@ -734,13 +844,18 @@ describe("legacy gen types", () => { ); expect(child.spawned[0]?.args).toContain("--network"); expect(child.spawned[0]?.args).toContain("host"); - expect(out.stderrText).toContain("Initialising login role..."); 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"); + 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); @@ -751,6 +866,193 @@ describe("legacy gen types", () => { ); } + 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("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: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("Preview branch database credentials are unavailable"); + } + }); + }); + it.live("maps project type generation network failures", () => { const { layer } = setup({ generateTypescriptTypes: () => Effect.fail(new Error("network error")), 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/shared/legacy-db-config.layer.ts b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts index 80e37455bb..f16f8ac569 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -468,7 +468,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 @@ -527,7 +527,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..2c68291116 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,13 @@ 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; } /** diff --git a/supabase/.temp/linked-project.json b/supabase/.temp/linked-project.json deleted file mode 100644 index 2098760851..0000000000 --- a/supabase/.temp/linked-project.json +++ /dev/null @@ -1 +0,0 @@ -{"ref":"hhrqlmthvbnwvlawqnwi","name":"rere","organization_id":"rnwamzlptflscprylent","organization_slug":"rnwamzlptflscprylent"} \ No newline at end of file