From 1ecb6359e6cc69a1731698bf035ca07980f132c8 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 16 Sep 2026 17:52:59 +0100 Subject: [PATCH 1/4] feat(cli): generate types natively against the selected stack (CLI-2366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gen types` no longer spawns a pg-meta container. Introspection and code generation run in-process through `@supabase/postgrest-typegen` over a direct PostgreSQL connection, so `--local` works against a stack whose runtime is native rather than Docker — previously impossible, since the stack has no pg-meta capability and a native stack has no Docker daemon to run one in. Generation goes through the shared `DbConnection` seam, which already owns TLS mode, DoH resolution, pooler fallback and connect-error classification, so no second connection path exists. The SSL probe is gone: linked, project-ref and preview-branch targets are known Supabase hosts and pin the bundled CA with `sslmode=require`; `--db-url` honours the DSN's own `sslmode`/`sslrootcert` and otherwise pins only for known Supabase hosts; `--local` stays plaintext. `--query-timeout` now maps to a server-enforced `statement_timeout`. `--network-id` is rejected on this command: in-process generation cannot join a Docker network. Both argv positions are covered, including the persistent form before the command path. Output is byte-identical to pg-meta v0.99.0 for Go and Swift. TypeScript types a NOT NULL jsonb column as `NonNullable` rather than `Json`, and Python uses pydantic's `JsonValue` rather than `Json[Any]`; both are upstream corrections and are documented in the command's SIDE_EFFECTS.md. Co-Authored-By: Claude Opus 5 --- apps/cli/package.json | 11 +- apps/cli/scripts/build-binary.ts | 2 + apps/cli/scripts/build.ts | 6 +- apps/cli/scripts/bundle-externals.ts | 15 + .../src/command-internal/connect-errors.ts | 5 + .../command-internal/db-connection.service.ts | 2 + .../db-connection.sql-pg.layer.ts | 20 +- apps/cli/src/command-internal/temp-paths.ts | 2 - .../command-internal/temp-paths.unit.test.ts | 1 - .../src/commands/gen/types/SIDE_EFFECTS.md | 132 +- .../src/commands/gen/types/types.e2e.test.ts | 43 +- .../src/commands/gen/types/types.errors.ts | 24 +- .../gen/types/types.generator.layer.ts | 78 + .../gen/types/types.generator.service.ts | 55 + .../gen/types/types.generator.unit.test.ts | 59 + .../src/commands/gen/types/types.handler.ts | 389 +- .../gen/types/types.integration.test.ts | 4106 ++++++----------- .../src/commands/gen/types/types.layers.ts | 11 +- .../cli/src/commands/gen/types/types.oxfmt.ts | 141 + .../src/commands/gen/types/types.shared.ts | 76 +- .../src/commands/gen/types/types.unit.test.ts | 185 - .../telemetry/__fixtures__/error-tags.txt | 3 +- apps/cli/tsconfig.types.json | 16 + pnpm-lock.yaml | 275 ++ pnpm-workspace.yaml | 1 + 25 files changed, 2402 insertions(+), 3256 deletions(-) create mode 100644 apps/cli/scripts/bundle-externals.ts create mode 100644 apps/cli/src/commands/gen/types/types.generator.layer.ts create mode 100644 apps/cli/src/commands/gen/types/types.generator.service.ts create mode 100644 apps/cli/src/commands/gen/types/types.generator.unit.test.ts create mode 100644 apps/cli/src/commands/gen/types/types.oxfmt.ts create mode 100644 apps/cli/tsconfig.types.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 700d6050cf..ec1dd44350 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -40,7 +40,7 @@ "test:e2e:run": "bun --bun vitest run --project e2e", "test:live": "bun --bun vitest run --project live", "test:smoke": "bun run tests/smoke-test.ts", - "types:check": "tsc --noEmit" + "types:check": "tsc --noEmit -p tsconfig.types.json" }, "dependencies": { "eciesjs": "^0.5.0", @@ -55,10 +55,19 @@ "@effect/vitest": "catalog:", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "^1.3.0", + "@oxfmt/binding-darwin-arm64": "0.66.0", + "@oxfmt/binding-darwin-x64": "0.66.0", + "@oxfmt/binding-linux-arm64-gnu": "0.66.0", + "@oxfmt/binding-linux-arm64-musl": "0.66.0", + "@oxfmt/binding-linux-x64-gnu": "0.66.0", + "@oxfmt/binding-linux-x64-musl": "0.66.0", + "@oxfmt/binding-win32-arm64-msvc": "0.66.0", + "@oxfmt/binding-win32-x64-msvc": "0.66.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.52", "@supabase/pg-topo": "1.0.0-alpha.6", + "@supabase/postgrest-typegen": "0.2.2", "@supabase/stack": "workspace:*", "@supabase/supabase-js": "catalog:", "@tsconfig/bun": "catalog:", diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 7d2a830ba2..0a6f25df37 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,4 +1,5 @@ import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; +import { OXFMT_OPTIONAL_PLUGIN_EXTERNALS } from "./bundle-externals.ts"; /** * Compiles the CLI to a standalone binary, run via `pnpm build:binary`. Embeds the pre-bundled @@ -18,6 +19,7 @@ if (packageJson.version === undefined || packageJson.version.length === 0) { const result = await Bun.build({ entrypoints: [entrypoint], compile: { outfile }, + external: [...OXFMT_OPTIONAL_PLUGIN_EXTERNALS], define: { SUPABASE_CLI_VERSION: JSON.stringify(packageJson.version), SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()), diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index d1581d44f7..87a9e3e3b4 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -5,6 +5,7 @@ import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; +import { OXFMT_OPTIONAL_PLUGIN_EXTERNALS } from "./bundle-externals.ts"; import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts"; const MUSL_TARGETS = [ @@ -113,7 +114,10 @@ function libcForBunTarget(target: string): "glibc" | "musl" | "" { } async function runBunBuild(config: Bun.BuildConfig) { - const result = await Bun.build(config); + const result = await Bun.build({ + ...config, + external: [...(config.external ?? []), ...OXFMT_OPTIONAL_PLUGIN_EXTERNALS], + }); for (const log of result.logs) { console.warn(log); } diff --git a/apps/cli/scripts/bundle-externals.ts b/apps/cli/scripts/bundle-externals.ts new file mode 100644 index 0000000000..5cee5d7355 --- /dev/null +++ b/apps/cli/scripts/bundle-externals.ts @@ -0,0 +1,15 @@ +/** + * Optional prettier plugins that `oxfmt`'s dist lazily `import()`s for non-TypeScript file + * types. They are never installed — `gen types` only formats generated TypeScript, through the + * statically embedded binding in `src/commands/gen/types/types.oxfmt.ts` — but `bun build` + * still resolves every analyzable dynamic import, so each must be marked external. + */ +export const OXFMT_OPTIONAL_PLUGIN_EXTERNALS = [ + "@prettier/plugin-hermes", + "@prettier/plugin-oxc", + "@prettier/plugin-pug", + "@shopify/prettier-plugin-liquid", + "@zackad/prettier-plugin-twig", + "prettier-plugin-astro", + "prettier-plugin-marko", +] as const; diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 2c89b40380..0af0dc678a 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -369,6 +369,11 @@ export function connectSuggestion( ) { return SUGGEST_ENV_VAR; } + // An unset `sslmode` negotiates TLS and fails rather than downgrading, so a server without + // TLS needs the caller to opt into plaintext explicitly. + if (text.includes(SERVER_REFUSED_SSL) || text.includes("server refused TLS connection")) { + return "This server does not accept TLS. Append `?sslmode=disable` to the connection string to connect in plaintext."; + } // Node system errors carry the dialed address as a structured field instead of libpq's // parenthesized literal, so also consult the errno + `address` classifier. if (isIPv6ConnectivityError(text) || hasIPv6DialCause(error)) { diff --git a/apps/cli/src/command-internal/db-connection.service.ts b/apps/cli/src/command-internal/db-connection.service.ts index dd43c3f086..abba632ff3 100644 --- a/apps/cli/src/command-internal/db-connection.service.ts +++ b/apps/cli/src/command-internal/db-connection.service.ts @@ -43,6 +43,8 @@ export interface PgConnInput { * `verify-ca`. Absent → system roots / no CA pinning. */ readonly sslrootcert?: string; + /** Inline PEM CA bundle; takes precedence over {@link sslrootcert} when both are set. */ + readonly sslrootcertInline?: string; /** * libpq client-certificate auth, from the DSN or `PGSSLCERT`/`PGSSLKEY`/`PGSSLPASSWORD`. * `sslcert`/`sslkey` are file paths loaded into the client cert; `sslpassword` decrypts an diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index 940243677a..74562a5519 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -688,15 +688,17 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC const rootcertPath = cfg.sslrootcert; const anyTcpTarget = dialTargets.some(({ dialHost }) => !isUnixSocketHost(dialHost)); const caCert = - rootcertPath !== undefined && rootcertPath.length > 0 && !isLocal && anyTcpTarget - ? yield* Effect.try({ - try: () => readFileSync(rootcertPath, "utf8"), - catch: (error) => - new DbConnectError({ - message: `failed to read sslrootcert ${rootcertPath}: ${error}`, - }), - }) - : undefined; + cfg.sslrootcertInline !== undefined && cfg.sslrootcertInline.length > 0 && !isLocal + ? cfg.sslrootcertInline + : rootcertPath !== undefined && rootcertPath.length > 0 && !isLocal && anyTcpTarget + ? yield* Effect.try({ + try: () => readFileSync(rootcertPath, "utf8"), + catch: (error) => + new DbConnectError({ + message: `failed to read sslrootcert ${rootcertPath}: ${error}`, + }), + }) + : undefined; // Loads the client `sslcert`/`sslkey` for cert auth, using the same non-local/TCP gate as // the CA bundle; `sslpassword` decrypts an encrypted key. Bound to locals so the narrowing diff --git a/apps/cli/src/command-internal/temp-paths.ts b/apps/cli/src/command-internal/temp-paths.ts index e3a67a8ead..b77f4f7240 100644 --- a/apps/cli/src/command-internal/temp-paths.ts +++ b/apps/cli/src/command-internal/temp-paths.ts @@ -32,7 +32,6 @@ export interface TempPaths { readonly gotrueVersion: string; readonly storageVersion: string; readonly storageMigration: string; - readonly pgmetaVersion: string; readonly linkedProjectCache: string; } @@ -47,7 +46,6 @@ export function tempPaths(path: Path.Path, workdir: string): TempPaths { gotrueVersion: path.join(tempDir, "gotrue-version"), storageVersion: path.join(tempDir, "storage-version"), storageMigration: path.join(tempDir, "storage-migration"), - pgmetaVersion: path.join(tempDir, "pgmeta-version"), linkedProjectCache: path.join(tempDir, "linked-project.json"), }; } diff --git a/apps/cli/src/command-internal/temp-paths.unit.test.ts b/apps/cli/src/command-internal/temp-paths.unit.test.ts index 1b9ce07daf..b217f3281c 100644 --- a/apps/cli/src/command-internal/temp-paths.unit.test.ts +++ b/apps/cli/src/command-internal/temp-paths.unit.test.ts @@ -33,7 +33,6 @@ describe("tempPaths", () => { expect(paths.gotrueVersion).toBe(path.join(tempDir, "gotrue-version")); expect(paths.storageVersion).toBe(path.join(tempDir, "storage-version")); expect(paths.storageMigration).toBe(path.join(tempDir, "storage-migration")); - expect(paths.pgmetaVersion).toBe(path.join(tempDir, "pgmeta-version")); expect(paths.linkedProjectCache).toBe(path.join(tempDir, "linked-project.json")); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 5eeab65ebb..07b496be85 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -1,8 +1,12 @@ # `supabase gen types` -When `[experimental].stack` is on, `--local` resolves the project stack through -`DbConfigResolver` and runs pg-meta on the host network. It does not inspect -`supabase_db_*`. +Generates PostgREST client types in-process via `@supabase/postgrest-typegen` +against a direct PostgreSQL connection. `--linked`/`--project-id` TypeScript +output still comes from the Management API; every other language and target +(including `--local` and `--db-url`) connects to the database and introspects +it directly — no pg-meta container is involved. When `[experimental].stack` +is on, `--local` resolves the project stack through `DbConfigResolver` +(`connType: "local"`) instead of inspecting `supabase_db_*`. ## Files Read @@ -12,7 +16,6 @@ When `[experimental].stack` is on, `--local` resolves the project stack through | `/supabase/config.toml` or `config.json` | TOML/JSON | when selecting schemas (`--linked`, `--project-id`, `--db-url`, and the implicit linked fallback — but not when `--schema` is also given on the two flag paths, which skip the load entirely). `--local` reads config.toml through its own tolerant reader (`readDbToml`) and always keeps the embedded-default fallback when the file is absent. On the other paths, a DEFAULTED workdir also keeps the embedded-default fallback (`included_schemas` falls back to `public,graphql_public`); an EXPLICIT `--workdir`/`SUPABASE_WORKDIR` that holds no project instead FAILS (`GenTypesMissingProjectConfigError`) rather than silently generating a `public`-only types file — see the exit-code table | | `{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the CLI | | `/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 | ## Files Written @@ -20,9 +23,7 @@ When `[experimental].stack` is on, `--local` resolves the project stack through | ---- | ------ | ---- | | — | — | — | -No files are written. Container env (including the DB URL and TLS CA bundle) is -passed via container CLI `run --env KEY=VALUE` arguments; no temporary env-file -is created. +No files are written. ## API Routes @@ -39,45 +40,41 @@ linked-project fallback when `--lang=typescript`. For other languages on those project-ref paths — a sanctioned intentional divergence, see Notes (CLI-1988) — 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, project-ref -pg-meta generation retries once through the IPv4 pooler only when the current target -host is the project's direct `db.` host and the pooler URL matches the expected -tenant and pooler domain. An explicit `--project-id` ref fetches the primary pooler -config for that ref to build the fallback 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. +host/port and credentials for the direct connection. Otherwise the database +connection is resolved for the ref and the login-role endpoint supplies temporary +credentials. On an IPv4-only network where the direct database host is unreachable, +project-ref generation retries once through the IPv4 pooler only when the current +target host is the project's direct `db.` host and the pooler URL matches the +expected tenant and pooler domain. An explicit `--project-id` ref fetches the +primary pooler config for that ref to build the fallback 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`/`podman container inspect supabase_db_` | `--local` | assert `supabase start` is running | -| `docker`/`podman image inspect ` per registry candidate, then `pull ` when none is cached | every pg-meta path | resolve the pg-meta image through the shared registry fallback (ECR, then GHCR, then Docker Hub, three attempts each; a slim image or `SUPABASE_INTERNAL_IMAGE_REGISTRY` pins a single candidate) after the TLS probe and before running it | -| `docker`/`podman 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. Always passes `node dist/server/server.js` after the image. Under `SUPABASE_USE_SLIM_IMAGES`, a current Dockerfile pin may resolve to slim `ghcr.io/supabase/cli/pgmeta`; a historical `.temp/pgmeta-version` pin stays on docker.io. | +| Command | When | Purpose | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| `docker`/`podman container inspect supabase_db_` | `--local`, only when the selected backend is the legacy Docker Compose stack (`[experimental].stack` off) | assert `supabase start` is running | -A raw TCP `SSLRequest` probe is also opened to the target database host/port to -detect TLS support before launching pg-meta, with the default 10s pg-delta probe -timeout. +Generation itself runs in-process and never shells out. On a native or +Docker-based managed stack (`[experimental].stack` on), `--local` never +inspects a container; it resolves the stack's database connection through +`DbConfigResolver` the same way `--db-url` does. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | -| `SUPABASE_DB_PORT` | local database probe port | no (defaults to `54322`) | -| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | -| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | -| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `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; any other value → that registry); pins a single registry, so the ECR → GHCR → Docker Hub fallback does not apply | no (defaults to the ECR registry) | -| `SUPABASE_USE_SLIM_IMAGES` | resolves the current Dockerfile pg-meta pin from the slim `ghcr.io/supabase/cli/pgmeta` build (`true`/`1` enable); a historical `.temp/pgmeta-version` pin stays on docker.io | no | -| `SUPABASE_CA_SKIP_VERIFY` | when `true`, prints a TLS-verification-disabled warning to stderr | no | -| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched** | +| Variable | Purpose | Required? | +| ---------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | +| `SUPABASE_DB_PORT` | local database port | no (defaults to `54322`) | +| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | +| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | +| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `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 to reach the local database on the legacy Docker Compose stack | no (defaults to `127.0.0.1`) | +| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched** | ## Exit Codes @@ -88,18 +85,18 @@ timeout. | `1` | mutually exclusive flags combined (all four Go flag groups) | | `1` | `--postgrest-v9-compat` used without `--db-url` | | `1` | invalid `--query-timeout` duration or invalid `--db-url` | -| `1` | `supabase start` not running (`--local`) or db inspection failed | +| `1` | `--network-id` set (`GenTypesNetworkIdUnsupportedError`) — generation runs in-process and cannot join a Docker network | +| `1` | `supabase start` not running (`--local` on the legacy Docker Compose stack) or db inspection failed | | `1` | resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a directory (`GenTypesWorkdirError`) — beats every other guard | | `1` | an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config on a schema-selecting path (`GenTypesMissingProjectConfigError`) — a DEFAULTED workdir keeps the embedded-default fallback instead | -| `1` | API error, TLS probe failure, or pg-meta container non-zero exit | -| `1` | no container runtime found, or the pg-meta image could not be inspected or pulled from any registry | +| `1` | API error or database connection/introspection/generation failure (`GenTypesGenerationError`) | ## Output ### `--output-format text` Prints generated TypeScript (or other language) type definitions to stdout. -Diagnostics (`Connecting to …`, pg-meta logs) go to stderr. +Diagnostics (`Connecting to …`) go to stderr. ### `--output-format json` @@ -117,41 +114,66 @@ Not applicable. each of `postgrest-v9-compat`, `query-timeout`, and `swift-access-control`. - With `--local`, a missing `supabase/config.toml` uses the embedded config defaults plus shell and nested dotenv overrides, matching the CLI. +- `--network-id` is rejected on this command: generation runs in-process over a direct + PostgreSQL connection, and there is no container to join a Docker network from. Use a + host-reachable `--db-url` instead. +- **TLS.** There is no SSL probe on any path. + - `--linked` / `--project-id` / the implicit linked fallback / a resolved preview + branch connect with `sslmode=require` and the bundled Supabase CA pinned + (promoted to `verify-ca`), matching prior behavior. + - `--db-url` honors the DSN's own `sslmode`/`sslrootcert` when either is set; + otherwise, a known Supabase host gets the Supabase CA pinned the same way, and any + other host uses the connection resolver's default. + - `--local` uses no TLS. - **Sanctioned intentional divergence (CLI-1988 parity ruling):** `--lang` accepts `typescript` (default), `go`, `swift`, or `python`. Project-ref paths (`--linked`, `--project-id`, and the implicit linked fallback) use the Management API - for TypeScript, and run pg-meta locally against the project database (temporary + for TypeScript, and connect directly to the project database (temporary login-role credentials, preview-branch fallback) for the other languages. The old Go CLI instead hard-errored with `Unable to generate types for selected project. -Try using --db-url flag instead.` and never ran pg-meta for a project ref. This - permissiveness is deliberate — it resolves the user-filed CLI-1623 complaint — and was - blessed in the CLI-1988 ruling; do not revert it to a hard error. The mutex groups only - block `--swift-access-control` / `--query-timeout` when `--linked`/`--project-id` is - passed _explicitly_ on the command line — that combination still always runs pg-meta +Try using --db-url flag instead.` and never generated types locally for a project ref. + This permissiveness is deliberate — it resolves the user-filed CLI-1623 complaint — and + was blessed in the CLI-1988 ruling; do not revert it to a hard error. The mutex groups + only block `--swift-access-control` / `--query-timeout` when `--linked`/`--project-id` + is passed _explicitly_ on the command line — that combination still always generates with defaults (`internal` access control, one-to-one detection on, 15s timeout). On the **implicit** linked fallback (none of `--local`/`--linked`/`--project-id`/`--db-url` passed), neither mutex key is set, so `--swift-access-control public` / - `--query-timeout 20s` clear every guard and ARE forwarded to pg-meta for `--lang + `--query-timeout 20s` clear every guard and ARE applied for `--lang go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only for the explicit `--linked`/`--project-id` paths. `--postgrest-v9-compat` is unaffected by this corner: its own gate requires `--db-url` regardless of how the project ref is resolved, so it stays blocked on every project-ref path. Use `--db-url` for guaranteed control over any of these three flags. +- **Output compatibility with the previous pg-meta-based generator.** Measured against + pg-meta's reference generators on the same schema: + - Go and Swift output are byte-identical. + - TypeScript: a `NOT NULL` jsonb column now generates `NonNullable` instead of + `Json` — the old type wrongly admitted `null` on a column the schema declares + non-nullable. + - Python: jsonb columns now generate `JsonValue` instead of pydantic's `Json[Any]`, + which expected an unparsed JSON _string_ rather than the already-decoded row value + typegen produces; `NotRequired` and `TypeAlias` are now imported from + `typing_extensions` instead of `typing`. + - `--linked`/`--project-id` TypeScript is still generated server-side by the + Management API and is unaffected by this change, so it can differ from local output + on these jsonb cases until the hosted service adopts the same generator. - `--schema` / `-s` accepts a comma-separated list of schemas to include. - `--swift-access-control` accepts `internal` (default) or `public`. It is mutually exclusive with an _explicit_ `--linked`/`--project-id`; on the `--local`, - `--db-url`, and implicit-linked-fallback paths it is always forwarded to pg-meta + `--db-url`, and implicit-linked-fallback paths it is always applied regardless of `--lang`. - `--postgrest-v9-compat` generates types compatible with PostgREST v9 and below. It must be used together with `--db-url` (error: `--postgrest-v9-compat must used together with --db-url` — note the typo, preserved intentionally). `--local` still forces v9 compat when the local PostgREST image tag contains `v9`. -- `--query-timeout` sets the maximum timeout for pg-meta database queries (default 15s). - It is mutually exclusive with an _explicit_ `--linked`/`--project-id`; on - the implicit linked fallback it is accepted, and forwarded to pg-meta for +- `--query-timeout` sets the maximum time allowed for introspection (default 15s), + applied both as the connection's server-side `statement_timeout` and as a connect + timeout. It is mutually exclusive with an _explicit_ `--linked`/`--project-id`; on + the implicit linked fallback it is accepted and applied for `--lang go`/`--lang swift`/`--lang python` (silently unused only for the implicit - linked TypeScript case, since that path never runs pg-meta). + linked TypeScript case, since that path never connects to the database directly). - The legacy positional language argument (`supabase gen types typescript`) is still accepted; any other positional language requires an explicit `--lang` flag. - The linked-project telemetry cache is written only when a project ref is resolved diff --git a/apps/cli/src/commands/gen/types/types.e2e.test.ts b/apps/cli/src/commands/gen/types/types.e2e.test.ts index e7516bbec1..992f6214fb 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -2,16 +2,14 @@ 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 { Effect } from "effect"; import { makeTempHome, makeTempStackProject, runSupabase } from "../../../../tests/helpers/cli.ts"; import { dockerfileServiceImage } from "../../../shared/services/dockerfile-images.ts"; -import { localDbContainerId, localNetworkId } from "../../../command-internal/docker-ids.ts"; +import { localDbContainerId } from "../../../command-internal/docker-ids.ts"; import { RESOLVE_BUDGET_MS, ensureImage, resolveDeadline, } from "../../../../tests/helpers/docker-image.ts"; -import { resolvePgmetaImage } from "./types.shared.ts"; const TYPEGEN_LANGS = ["typescript", "go", "swift", "python"] as const; type TypegenLang = (typeof TYPEGEN_LANGS)[number]; @@ -204,26 +202,16 @@ async function waitForLocalPostgres(containerName: string) { ); } -// Pre-pulls pg-meta inside the image budget and retags the winning candidate onto the -// reference `gen types` resolves, so the CLI's own resolver takes the cached path. -async function ensurePgmetaImage(deadline?: number) { - const expected = await Effect.runPromise(resolvePgmetaImage()); - const resolved = await ensureImage(dockerfileServiceImage("pgmeta"), deadline); - if (resolved !== expected) { - await expectDockerSucceeded(["tag", resolved, expected], 30_000); - } -} - +/** + * Starts a bare Postgres container named for `assertLocalDbRunning`'s `container inspect` check. + * Generation itself runs in-process against the host-mapped port, so — unlike the pg-meta-era + * setup this replaces — no Docker network or network alias is needed here. + */ async function startLocalPostgres(input: { readonly projectId: string; readonly dbPort: number }) { const containerName = localDbContainerId(input.projectId); - const networkName = localNetworkId(input.projectId); - // Reserves pg-meta's slice of the shared window up front so Postgres pull time can't - // starve it. const imageDeadline = resolveDeadline(LOCAL_IMAGE_BUDGET_MS); const postgresImage = await ensureImage(LOCAL_POSTGRES_IMAGE, imageDeadline - RESOLVE_BUDGET_MS); - await ensurePgmetaImage(imageDeadline); - await expectDockerSucceeded(["network", "create", networkName], 30_000); await expectDockerSucceeded( [ "run", @@ -231,10 +219,6 @@ async function startLocalPostgres(input: { readonly projectId: string; readonly "--rm", "--name", containerName, - "--network", - networkName, - "--network-alias", - "db", "-p", `${input.dbPort}:5432`, "-e", @@ -254,7 +238,7 @@ async function startLocalPostgres(input: { readonly projectId: string; readonly ); await waitForLocalPostgres(containerName); - return { containerName, networkName }; + return { containerName }; } async function seedSmokeTable(containerName: string) { @@ -285,12 +269,8 @@ async function seedSmokeTable(containerName: string) { ); } -async function cleanupLocalPostgres(input: { - readonly containerName: string; - readonly networkName: string; -}) { +async function cleanupLocalPostgres(input: { readonly containerName: 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 }) { @@ -341,10 +321,7 @@ describe("gen types e2e", () => { const projectId = `typegen${project.ports.dbPort}`; const profilePath = await writeOfflineProfile(project.dir); const env = tokenlessEnv(profilePath, project.dir); - const localPostgres = { - containerName: localDbContainerId(projectId), - networkName: localNetworkId(projectId), - }; + const localPostgres = { containerName: localDbContainerId(projectId) }; try { await writeLocalConfig(project.dir, projectId, project.ports.dbPort); @@ -396,8 +373,6 @@ describe("gen types e2e", () => { ); } - await ensurePgmetaImage(); - for (const lang of TYPEGEN_LANGS) { const result = await runSupabase( ["gen", "types", "--project-id", remoteProjectRef, "--lang", lang, "--schema", "public"], diff --git a/apps/cli/src/commands/gen/types/types.errors.ts b/apps/cli/src/commands/gen/types/types.errors.ts index 3cd63213f2..d197ae6cca 100644 --- a/apps/cli/src/commands/gen/types/types.errors.ts +++ b/apps/cli/src/commands/gen/types/types.errors.ts @@ -37,16 +37,6 @@ export class InvalidGenTypesDurationError extends Data.TaggedError("InvalidGenTy } } -export class InvalidGenTypesDatabaseUrlError extends Data.TaggedError( - "InvalidGenTypesDatabaseUrlError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; - } -} - /** * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a * directory (`validateWorkdirIsDirectory`). Only reachable when the @@ -87,3 +77,17 @@ export class GenTypesMissingProjectConfigError extends Data.TaggedError( return actionability.provideFlags; } } + +/** + * Raised when `--network-id` is set: `gen types` now generates in-process instead of running a + * pg-meta container, so there is no Docker network to join. + */ +export class GenTypesNetworkIdUnsupportedError extends Data.TaggedError( + "GenTypesNetworkIdUnsupportedError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/commands/gen/types/types.generator.layer.ts b/apps/cli/src/commands/gen/types/types.generator.layer.ts new file mode 100644 index 0000000000..ebfa400881 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.generator.layer.ts @@ -0,0 +1,78 @@ +import { Effect, Layer } from "effect"; +import { + generateGo, + generatePython, + generateSwift, + generateTypescript, + introspect, + sortGeneratorMetadata, + type Queryable, +} from "@supabase/postgrest-typegen"; + +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; +import { oxfmtTypegenFormat } from "./types.oxfmt.ts"; +import { GenTypesGenerationError, GenTypesGenerator } from "./types.generator.service.ts"; + +/** + * pg-meta printed generated output through `console.log`, which appends a newline regardless of + * what the generator already emitted — the TypeScript generator ends with one, so its output + * gained a blank final line. Appending unconditionally keeps every language byte-identical. + */ +function withTrailingNewline(code: string): string { + return `${code}\n`; +} + +/** + * Live `GenTypesGenerator`: opens a `DbConnection` session, adapts it to the generator's + * `Queryable` contract, and runs introspection and code generation in-process, replacing the + * pg-meta Docker container `gen types` previously shelled out to. + */ +export const genTypesGeneratorLayer = Layer.effect( + GenTypesGenerator, + Effect.gen(function* () { + const dbConn = yield* DbConnection; + return GenTypesGenerator.of({ + generate: (input) => + Effect.gen(function* () { + const session = yield* dbConn.connect(input.conn, { + isLocal: input.isLocal, + dnsResolver: input.dnsResolver, + }); + // `session.query` needs no services, but running it through the current fiber's + // context (rather than a bare detached `Effect.runPromise`) keeps the Promise bridge + // anchored to this generator effect instead of a disconnected top-level runtime. + const runQuery = Effect.runPromiseWith(yield* Effect.context()); + const queryable: Queryable = { + query: (sql) => runQuery(session.query(sql)).then((rows) => ({ rows: [...rows] })), + }; + return yield* Effect.tryPromise({ + try: async () => { + const metadata = sortGeneratorMetadata( + await introspect(queryable, { includedSchemas: [...input.includedSchemas] }), + ); + switch (input.lang) { + case "typescript": + return await generateTypescript(metadata, { + detectOneToOneRelationships: input.detectOneToOneRelationships, + format: oxfmtTypegenFormat, + }); + case "go": + return generateGo(metadata); + case "python": + return generatePython(metadata); + case "swift": + return generateSwift(metadata, { accessControl: input.swiftAccessControl }); + } + }, + catch: (cause) => + new GenTypesGenerationError({ + message: `failed to generate ${input.lang} types: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + cause, + }), + }).pipe(Effect.map(withTrailingNewline)); + }), + }); + }), +); diff --git a/apps/cli/src/commands/gen/types/types.generator.service.ts b/apps/cli/src/commands/gen/types/types.generator.service.ts new file mode 100644 index 0000000000..14e04ea579 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.generator.service.ts @@ -0,0 +1,55 @@ +import { Context, Data, type Effect, type Scope } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; +import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; +import type { + DbConnectOptions, + PgConnInput, +} from "../../../command-internal/db-connection.service.ts"; + +/** Output language `gen types` can produce, mirroring the `@supabase/postgrest-typegen` generators. */ +type GenTypesLanguage = "typescript" | "go" | "python" | "swift"; + +/** + * Swift access-control levels `gen types` exposes. The underlying generator also accepts + * `"private"`/`"package"`, which this command does not surface. + */ +type GenTypesSwiftAccessControl = "internal" | "public"; + +export interface GenTypesGenerateInput { + readonly conn: PgConnInput; + readonly isLocal: boolean; + readonly dnsResolver: DbConnectOptions["dnsResolver"]; + readonly lang: GenTypesLanguage; + readonly includedSchemas: ReadonlyArray; + readonly detectOneToOneRelationships: boolean; + readonly swiftAccessControl: GenTypesSwiftAccessControl; +} + +interface GenTypesGeneratorShape { + /** Connects to `input.conn`, introspects it, and generates `input.lang` source. */ + readonly generate: ( + input: GenTypesGenerateInput, + ) => Effect.Effect; +} + +/** Introspection or code generation failed against the target database's schema. */ +export class GenTypesGenerationError extends Data.TaggedError("GenTypesGenerationError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} + +/** + * Generates PostgREST client types in-process via `@supabase/postgrest-typegen`, replacing the + * pg-meta Docker container `gen types` previously shelled out to. + */ +export class GenTypesGenerator extends Context.Service()( + "supabase/cli/GenTypesGenerator", +) {} diff --git a/apps/cli/src/commands/gen/types/types.generator.unit.test.ts b/apps/cli/src/commands/gen/types/types.generator.unit.test.ts new file mode 100644 index 0000000000..1e0546fc5c --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.generator.unit.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + generateGo, + generatePython, + generateSwift, + generateTypescript, + introspect, + sortGeneratorMetadata, + type GeneratorMetadata, +} from "@supabase/postgrest-typegen"; + +import { oxfmtTypegenFormat } from "./types.oxfmt.ts"; + +const emptyMetadata: GeneratorMetadata = { + version: 1, + schemas: [{ id: 1, name: "public", owner: "postgres" }], + tables: [], + views: [], + materializedViews: [], + foreignTables: [], + columns: [], + primaryKeys: [], + relationships: [], + functions: [], + types: [], +}; + +/** + * `tsconfig.types.json` type-checks this package against its published `dist/*.d.ts`, while Bun + * resolves its `bun` exports condition to `src/*.ts` at runtime. These assertions run against the + * Bun-resolved module, so a drift between the two views fails here rather than at generation time. + */ +describe("postgrest-typegen runtime contract", () => { + it("exposes the introspection and generation entry points the generator layer calls", () => { + expect(typeof introspect).toBe("function"); + expect(typeof sortGeneratorMetadata).toBe("function"); + expect(typeof generateTypescript).toBe("function"); + expect(typeof generateGo).toBe("function"); + expect(typeof generatePython).toBe("function"); + expect(typeof generateSwift).toBe("function"); + }); + + it("renders every supported language from metadata alone", async () => { + const metadata = sortGeneratorMetadata(emptyMetadata); + + await expect(generateTypescript(metadata, { format: oxfmtTypegenFormat })).resolves.toContain( + "public", + ); + expect(generateGo(metadata)).toContain("package"); + expect(generatePython(metadata)).toContain("import"); + expect(generateSwift(metadata, { accessControl: "internal" })).toContain("import Supabase"); + }); + + it("formats through the statically embedded oxfmt binding", async () => { + await expect(oxfmtTypegenFormat("export type A={a:string|null}\n")).resolves.toBe( + "export type A = { a: string | null }\n", + ); + }); +}); diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 4f4943c363..55f5d7dd8c 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -1,8 +1,9 @@ import type { LoadedCliConfig } from "@supabase/config/effect"; import { loadCliConfig } from "@supabase/config/internal"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Config, Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } from "effect"; -import { DnsResolverFlag, NetworkIdFlag } from "../../../command-internal/global-flags.ts"; +import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; +import { getDomain } from "tldts"; +import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, @@ -17,11 +18,7 @@ import { PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/project-ref.service.ts"; import { spawnContainerCli } from "../../../command-internal/container-cli.ts"; -import { makeDockerImageResolver } from "../../../command-internal/docker-image-resolve.ts"; -import { - isIPv6ConnectivityError, - isIPv6ConnectivityErrorCause, -} from "../../../command-internal/connect-errors.ts"; +import { isIPv6ConnectivityErrorCause } from "../../../command-internal/connect-errors.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import type { DbConfigFlags } from "../../../command-internal/db-config.types.ts"; @@ -29,7 +26,6 @@ import { poolerConfigFromConnectionString } from "../../../command-internal/db-c import { readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; -import { toPostgresURL } from "../../../command-internal/postgres-url.ts"; import { tempPaths } from "../../../command-internal/temp-paths.ts"; import { missingProjectConfigMessageEffect, @@ -39,7 +35,6 @@ import { shouldSearchAncestors } from "../../../command-internal/workdir-search. import { validateWorkdirIsDirectory } from "../../../command-internal/workdir-validation.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { PgDeltaSslProbe } from "../../../command-internal/pgdelta-ssl-probe.service.ts"; import { isDirectDbHost, runWithPoolerFallback, @@ -48,27 +43,20 @@ import type { GenTypesFlags } from "./types.command.ts"; import { GenTypesMissingProjectConfigError, GenTypesNetworkError, + GenTypesNetworkIdUnsupportedError, GenTypesParseConfigError, GenTypesUnexpectedStatusError, GenTypesWorkdirError, } from "./types.errors.ts"; +import { GenTypesGenerator } from "./types.generator.service.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; -import { - rewriteDumpHostForToolContainer, - toolContainerUsesHostNetwork, -} from "../../../command-internal/postgres-client.run.ts"; -import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { defaultSchemas, - buildPostgresUrl, localDbContainerId, localDbPassword, - localNetworkId, - parseDatabaseUrl, parseQueryTimeoutSeconds, rootCaBundle, - resolvePgmetaImage, } from "./types.shared.ts"; const mapProjectTypesError = mapHttpError({ @@ -99,6 +87,18 @@ function isProjectNotFound(cause: unknown) { return cause instanceof GenTypesUnexpectedStatusError && cause.status === 404; } +/** Pins the Supabase CA on a known-Supabase target, promoting `require` to `verify-ca`. */ +function pinSupabaseTls(conn: PgConnInput): PgConnInput { + return { ...conn, sslmode: "require", sslrootcertInline: rootCaBundle() }; +} + +/** Whether `host`'s registrable domain matches the active profile's pooler domain. */ +function isPoolerHost(host: string, poolerHost: string): boolean { + if (poolerHost.length === 0) return false; + const domain = getDomain(host); + return domain !== null && domain.toLowerCase() === poolerHost.toLowerCase(); +} + const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; type GenTypesMutexFlag = @@ -138,16 +138,6 @@ const GEN_TYPES_SCAN_SPEC = { valueFlagShorthands: new Map([["s", "schema"], ...PERSISTENT_VALUE_FLAG_SHORTHANDS]), } as const; -function forwardByteStream( - stream: Stream.Stream, - write: (text: string) => Effect.Effect, -) { - const decoder = new TextDecoder(); - return Stream.runForEach(stream, (chunk) => write(decoder.decode(chunk, { stream: true }))).pipe( - Effect.andThen(write(decoder.decode())), - ); -} - function collectByteStream(stream: Stream.Stream) { const decoder = new TextDecoder(); return Stream.runFold( @@ -228,7 +218,6 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stdio = yield* Stdio.Stdio; - const networkId = yield* NetworkIdFlag; const dnsResolver = yield* DnsResolverFlag; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const rawArgs = yield* stdio.args; @@ -236,8 +225,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const projectRef = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const dbConfig = yield* DbConfigResolver; - const sslProbe = yield* PgDeltaSslProbe; - const runtimeInfo = yield* RuntimeInfo; + const generator = yield* GenTypesGenerator; const backend = yield* currentStackBackend; // "Set" means the flag appeared in argv at all (pflag's `Changed` semantics), not its parsed @@ -297,6 +285,61 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const schemasFromConfig = (apiSchemas: ReadonlyArray | undefined) => defaultSchemas(apiSchemas); + /** + * Sets a session-level `statement_timeout` and connect timeout from `--query-timeout` on + * every generate attempt — the server-side `statement_timeout` is the real guard, unlike the + * pg-meta container's own env-var timeouts it replaces. + */ + const withQueryTimeout = (conn: PgConnInput): PgConnInput => ({ + ...conn, + runtimeParams: { ...conn.runtimeParams, statement_timeout: String(queryTimeoutSeconds * 1000) }, + connectTimeoutSeconds: queryTimeoutSeconds, + }); + + const runGenerate = (input: { + readonly conn: PgConnInput; + readonly isLocal: boolean; + readonly includedSchemas: ReadonlyArray; + readonly detectOneToOneRelationships: boolean; + readonly poolerFallback?: { + readonly directHost: string; + readonly eligible: boolean; + readonly resolve: Effect.Effect, unknown>; + }; + }) => + Effect.gen(function* () { + const attempt = (conn: PgConnInput) => + Effect.scoped( + Effect.gen(function* () { + const target = withQueryTimeout(conn); + yield* output.raw(`Connecting to ${target.host} ${target.port}\n`, "stderr"); + return yield* generator.generate({ + conn: target, + isLocal: input.isLocal, + dnsResolver, + lang, + includedSchemas: input.includedSchemas, + detectOneToOneRelationships: input.detectOneToOneRelationships, + swiftAccessControl, + }); + }), + ); + + const types = + input.poolerFallback === undefined + ? yield* attempt(input.conn) + : yield* runWithPoolerFallback({ + run: attempt(input.conn), + retry: attempt, + directHost: input.poolerFallback.directHost, + eligible: input.poolerFallback.eligible, + resolveFallback: input.poolerFallback.resolve, + classifyError: isIPv6ConnectivityErrorCause, + }); + + yield* output.raw(types); + }); + const runProjectTypes = ( projectRef: string, includedSchemas: ReadonlyArray, @@ -329,20 +372,18 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) adHocProjectRef, }; const resolved = yield* dbConfig.resolve(resolveFlags); - const conn = resolved.conn; - yield* runPgMeta({ - url: toPostgresURL(conn), - host: conn.host, - port: conn.port, - probeHost: conn.host, - probePort: conn.port, - networkMode: "host", - includedSchemas: includedSchemas.join(","), - postgrestV9Compat: flags.postgrestV9Compat, + const conn = pinSupabaseTls(resolved.conn); + yield* runGenerate({ + conn, + isLocal: resolved.isLocal, + includedSchemas, + detectOneToOneRelationships: !flags.postgrestV9Compat, poolerFallback: { directHost: conn.host, eligible: !resolved.isLocal && isDirectDbHost(conn.host, cliSettings.projectHost), - resolve: dbConfig.resolvePoolerFallback(resolveFlags), + resolve: dbConfig + .resolvePoolerFallback(resolveFlags) + .pipe(Effect.map(Option.map(pinSupabaseTls))), }, }); return; @@ -381,27 +422,23 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) cliSettings.poolerHost, ); return parsed._tag === "ok" - ? Option.some({ ...parsed.conn, password: branchPassword }) + ? Option.some(pinSupabaseTls({ ...parsed.conn, password: branchPassword })) : Option.none(); }), Effect.orElseSucceed(() => Option.none()), ); - yield* runPgMeta({ - url: toPostgresURL({ + yield* runGenerate({ + conn: pinSupabaseTls({ host: branch.db_host, port: branch.db_port, user: branchUser, password: branchPassword, 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, + isLocal: false, + includedSchemas, + detectOneToOneRelationships: !flags.postgrestV9Compat, poolerFallback: { directHost: branch.db_host, eligible: isDirectDbHost(branch.db_host, cliSettings.projectHost), @@ -410,153 +447,6 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) }); }); - const runPgMeta = (input: { - readonly url: string; - readonly host: string; - readonly port: number; - readonly probeHost: string; - readonly probePort: number; - readonly networkMode: "host" | (string & {}); - readonly includedSchemas: string; - readonly postgrestV9Compat: boolean; - readonly pgmetaVersionOverride?: string; - readonly projectEnvValues?: Readonly>; - readonly poolerFallback?: { - readonly directHost: string; - readonly eligible: boolean; - readonly resolve: Effect.Effect, unknown>; - }; - }) => - Effect.scoped( - Effect.gen(function* () { - // Cached so the pooler retry reuses one resolve; the resolver's candidate rewrite is - // idempotent on this already-rewritten reference. - const resolvedImage = yield* Effect.cached( - makeDockerImageResolver( - spawner, - input.projectEnvValues, - input.projectEnvValues, - )(yield* resolvePgmetaImage(input.pgmetaVersionOverride, input.projectEnvValues)), - ); - const buildRun = (target: { - readonly url: string; - readonly host: string; - readonly port: number; - readonly probeHost: string; - readonly probePort: number; - }) => - Effect.gen(function* () { - yield* output.raw(`Connecting to ${target.host} ${target.port}\n`, "stderr"); - - // Passed as `--env KEY=VALUE` args rather than `--env-file`: env-files split on - // newlines and can't carry the multi-line PEM CA bundle without injecting an - // extra variable. - const env = [ - `PG_META_DB_URL=${target.url}`, - `PG_CONN_TIMEOUT_SECS=${queryTimeoutSeconds}`, - `PG_QUERY_TIMEOUT_SECS=${queryTimeoutSeconds}`, - `PG_META_GENERATE_TYPES=${lang}`, - `PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=${input.includedSchemas}`, - `PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=${swiftAccessControl}`, - `PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=${String(!input.postgrestV9Compat)}`, - ]; - - // The SSL probe never verifies certificates on its own, so honor the same env var - // here too when warning about disabled verification. - const caSkipProjectValue = Option.fromNullishOr( - input.projectEnvValues?.["SUPABASE_CA_SKIP_VERIFY"], - ); - const caSkipVerify = Option.isSome(caSkipProjectValue) - ? caSkipProjectValue.value - : yield* Config.string("SUPABASE_CA_SKIP_VERIFY").pipe(Config.withDefault("")); - if (caSkipVerify === "true") { - yield* output.raw( - "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)\n", - "stderr", - ); - } - - const useTls = yield* sslProbe.requireSslForHost(target.probeHost, target.probePort); - if (useTls) { - env.push(`PG_META_DB_SSL_ROOT_CERT=${rootCaBundle()}`); - } - // After the TLS probe, so an unreachable database fails before any image pull. - const pgmetaImage = yield* resolvedImage; - - // `--network-id` overrides any base network mode, including "host" for --db-url. - const networkMode = Option.isSome(networkId) ? networkId.value : input.networkMode; - // Linux needs an explicit gateway mapping; Docker Desktop platforms already provide it. - const extraHosts = - runtimeInfo.platform === "linux" - ? (["--add-host", "host.docker.internal:host-gateway"] as const) - : []; - const args = [ - "run", - "--rm", - "--network", - networkMode, - ...extraHosts, - ...env.flatMap((entry) => ["--env", entry]), - pgmetaImage, - "node", - "dist/server/server.js", - ]; - const child = yield* spawnContainerCli(spawner, args, { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - env: input.projectEnvValues === undefined ? undefined : { ...input.projectEnvValues }, - extendEnv: true, - }); - - let stderrText = ""; - const [exitCode] = yield* Effect.all( - [ - child.exitCode.pipe(Effect.map(Number)), - forwardByteStream(child.stdout, (text) => output.raw(text, "stdout")), - forwardByteStream(child.stderr, (text) => - Effect.sync(() => { - stderrText += text; - }).pipe(Effect.andThen(output.raw(text, "stderr"))), - ), - ], - { concurrency: "unbounded" }, - ); - return { exitCode, stderrText }; - }); - - const runTarget = (conn: PgConnInput) => - buildRun({ - url: toPostgresURL(conn), - host: conn.host, - port: conn.port, - probeHost: conn.host, - probePort: conn.port, - }); - - const result = - input.poolerFallback === undefined - ? yield* buildRun(input) - : yield* runWithPoolerFallback({ - run: buildRun(input), - retry: runTarget, - directHost: input.poolerFallback.directHost, - eligible: input.poolerFallback.eligible, - resolveFallback: input.poolerFallback.resolve, - // A registry failure carries docker stderr that can read like an IPv6 error. - classifyError: (error) => - !Predicate.isTagged(error, "DockerRunError") && - isIPv6ConnectivityErrorCause(error), - classifyResult: (result) => - result.exitCode !== 0 && isIPv6ConnectivityError(result.stderrText), - }); - - if (result.exitCode !== 0) { - return yield* Effect.fail(new Error(`error running container: exit ${result.exitCode}`)); - } - }), - ); - const assertLocalDbRunning = ( projectId: string, projectEnvValues?: Readonly>, @@ -603,6 +493,19 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) Effect.mapError((error) => new GenTypesWorkdirError({ message: error.message })), ); + // `--network-id` can no longer be honored: generation runs in-process, not in a Docker + // container. It is a persistent flag, so a pre-command occurrence (`supabase --network-id + // net gen types ...`) lands in `prePathOccurrences`, not `occurrences` — check both. + if (occurrences.has("network-id") || scan.prePathOccurrences.has("network-id")) { + return yield* Effect.fail( + new GenTypesNetworkIdUnsupportedError({ + message: + "gen types now generates types in-process and cannot join a Docker network via " + + "--network-id; use a host-reachable --db-url instead.", + }), + ); + } + // This guard runs before flag-group validation, so its error wins when both apply. Both // run after the telemetry context is installed, so every return here must stay inside the // `Effect.ensuring(telemetryState.flush)` below. @@ -660,63 +563,35 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) .pipe(Effect.orElseSucceed(() => ""))).trim() : ""; const forcedV9 = restVersion.length > 0 && restVersion.includes("v9"); - const pgmetaVersionOverride = yield* fs - .readFileString(paths.pgmetaVersion) - .pipe(Effect.orElseSucceed(() => "")); - const includedSchemas = ( - schemas.length > 0 ? schemas : defaultSchemas(config.apiSchemas) - ).join(","); + const includedSchemas = schemas.length > 0 ? schemas : defaultSchemas(config.apiSchemas); if (backend.kind === "stack") { const resolved = yield* dbConfig.resolve({ dbUrl: Option.none(), connType: "local", dnsResolver, }); - const usesHostNetwork = toolContainerUsesHostNetwork(Option.getOrUndefined(networkId)); - const toolHost = rewriteDumpHostForToolContainer(resolved.conn.host, { - platform: runtimeInfo.platform, - usesHostNetwork, - }); - yield* runPgMeta({ - url: buildPostgresUrl({ - host: toolHost, - port: resolved.conn.port, - user: resolved.conn.user, - password: resolved.conn.password, - database: resolved.conn.database, - }), - host: toolHost, - port: resolved.conn.port, - probeHost: resolved.conn.host, - probePort: resolved.conn.port, - networkMode: "host", + yield* runGenerate({ + conn: resolved.conn, + isLocal: true, includedSchemas, - postgrestV9Compat: flags.postgrestV9Compat || forcedV9, - pgmetaVersionOverride, - projectEnvValues, + detectOneToOneRelationships: !(flags.postgrestV9Compat || forcedV9), }); return; } yield* assertLocalDbRunning(projectId, projectEnvValues); - yield* runPgMeta({ - url: buildPostgresUrl({ - host: "db", - port: 5432, + yield* runGenerate({ + conn: { + host: yield* getHostname(projectEnvValues), + port: config.port, user: "postgres", password: yield* localDbPassword(), database: "postgres", - }), - host: "db", - port: 5432, - probeHost: yield* getHostname(projectEnvValues), - probePort: config.port, - networkMode: localNetworkId(projectId), + }, + isLocal: true, includedSchemas, - postgrestV9Compat: flags.postgrestV9Compat || forcedV9, - pgmetaVersionOverride, - projectEnvValues, + detectOneToOneRelationships: !(flags.postgrestV9Compat || forcedV9), }); return; } @@ -726,20 +601,30 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) // output here is the schema fallback — a `--db-url --schema ...` invocation must not // fail just because the workdir has no project config. const loaded = schemas.length > 0 ? null : yield* loadConfig(); - const direct = yield* parseDatabaseUrl(flags.dbUrl.value); - const includedSchemas = ( - schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas ?? []) - ).join(","); - - yield* runPgMeta({ - url: direct.url, - host: direct.host, - port: direct.port, - probeHost: direct.host, - probePort: direct.port, - networkMode: direct.networkMode, + const resolved = yield* dbConfig.resolve({ + dbUrl: flags.dbUrl, + connType: "db-url", + dnsResolver, + }); + const includedSchemas = + schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas ?? []); + + // A DSN's own `sslmode`/`sslrootcert` is honored as-is; only a known Supabase host with + // neither set gets the CA pinned, matching the project-ref/branch paths. + const conn = + !resolved.isLocal && + resolved.conn.sslmode === undefined && + resolved.conn.sslrootcert === undefined && + (isDirectDbHost(resolved.conn.host, cliSettings.projectHost) || + isPoolerHost(resolved.conn.host, cliSettings.poolerHost)) + ? pinSupabaseTls(resolved.conn) + : resolved.conn; + + yield* runGenerate({ + conn, + isLocal: resolved.isLocal, includedSchemas, - postgrestV9Compat: flags.postgrestV9Compat, + detectOneToOneRelationships: !flags.postgrestV9Compat, }); return; } diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index b54c9ae5b9..0da724edc8 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1,5 +1,4 @@ -import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; @@ -11,8 +10,6 @@ import type { V1GetProjectOutput, } from "@supabase/api/effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import type * as ChildProcess from "effect/unstable/process/ChildProcess"; -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"; @@ -28,23 +25,10 @@ import { Stdio, Stream, } from "effect"; -import { - GLOBAL_FLAGS, - DebugFlag, - DnsResolverFlag, - NetworkIdFlag, - OutputFlag, -} from "../../../command-internal/global-flags.ts"; +import { DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { - mockAnalytics, - mockOutput, - mockProcessControl, - mockRuntimeInfo, - mockTty, - processEnvLayer, -} from "../../../../tests/helpers/mocks.ts"; +import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, @@ -53,34 +37,20 @@ import { mockCommandPlatformApiService, mockTelemetryStateTracked, } from "../../../../tests/helpers/command-mocks.ts"; -import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; -import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; -import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../../../shared/services/slim-images.ts"; -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 { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import type { DbConfigError } from "../../../command-internal/db-config.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; -import { - PgDeltaSslProbe, - PgDeltaSslProbeError, -} from "../../../command-internal/pgdelta-ssl-probe.service.ts"; -import { pgDeltaSslProbeLayer } from "../../../command-internal/pgdelta-ssl-probe.layer.ts"; -import { getRegistryImageUrlCandidates } from "../../../command-internal/docker-registry.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; -import { genCommand } from "../gen.command.ts"; import type { GenTypesFlags } from "./types.command.ts"; import { genTypes } from "./types.handler.ts"; -import { - localDbContainerId, - localNetworkId, - parseQueryTimeoutSeconds, - resolvePgmetaImage, -} from "./types.shared.ts"; +import { localDbContainerId, parseQueryTimeoutSeconds, rootCaBundle } from "./types.shared.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; +import { + GenTypesGenerationError, + GenTypesGenerator, + type GenTypesGenerateInput, +} from "./types.generator.service.ts"; function writeConfig(workdir: string, contents: string) { const supabaseDir = join(workdir, "supabase"); @@ -102,42 +72,6 @@ function ensureDefaultConfig(workdir: string) { writeConfig(workdir, ['project_id = "demo"', "", "[api]", "schemas = []"].join("\n")); } -/** Extracts the `KEY=VALUE` entries passed via `docker run --env ` arguments. */ -function dockerEnv(args: ReadonlyArray) { - const entries: string[] = []; - for (let index = 0; index < args.length; index += 1) { - if (args[index] === "--env") { - const entry = args[index + 1]; - if (entry !== undefined) { - entries.push(entry); - } - } - } - return { - entries, - has: (entry: string) => entries.includes(entry), - startsWith: (prefix: string) => entries.some((entry) => entry.startsWith(prefix)), - }; -} - -/** The argv of the `docker run` invocation captured during a spawn. */ -function captureDockerRun() { - let args: ReadonlyArray | undefined; - return { - onSpawn: (record: { readonly command: string; readonly args: ReadonlyArray }) => { - if (record.command === "docker" && record.args.includes("run")) { - args = record.args; - } - }, - get args() { - return args; - }, - get env() { - return dockerEnv(args ?? []); - }, - }; -} - function defaultFlags(overrides: Partial = {}): GenTypesFlags { return { local: false, @@ -212,6 +146,131 @@ function mockDbConfigResolver( return { layer, resolves, poolerFallbacks }; } +/** Records `GenTypesGenerator.generate` invocations and answers each with a canned output. */ +function mockGenTypesGenerator( + opts: { + readonly generate?: ( + input: GenTypesGenerateInput, + callIndex: number, + ) => Effect.Effect; + readonly output?: string; + } = {}, +) { + const calls: Array = []; + const layer = Layer.succeed(GenTypesGenerator, { + generate: (input) => + Effect.suspend(() => { + calls.push(input); + return ( + opts.generate?.(input, calls.length - 1) ?? Effect.succeed(opts.output ?? "generated") + ); + }), + }); + return { + layer, + get calls() { + return calls; + }, + }; +} + +/** One `GenTypesGenerator.generate` outcome per attempt — models a failing then a retried call. */ +function sequentialGenerator( + steps: ReadonlyArray<() => Effect.Effect>, +) { + return mockGenTypesGenerator({ + generate: (_input, index) => + (steps[Math.min(index, steps.length - 1)] ?? (() => Effect.succeed("generated")))(), + }); +} + +function ipv6Failure(lang = "go") { + return new GenTypesGenerationError({ + message: `failed to generate ${lang} types: could not translate host name to address: No address associated with hostname`, + }); +} + +function nonIpv6Failure(lang = "go") { + return new GenTypesGenerationError({ + message: `failed to generate ${lang} types: permission denied for schema public`, + }); +} + +/** + * A single `container inspect` spawn — the only subprocess `gen types --local` still shells out + * to (via `assertLocalDbRunning`) now that generation itself runs in-process. `dockerMissing` + * fails the `docker` attempt with a not-found error so the fallback `podman` attempt is exercised. + */ +function mockInspectSpawner( + opts: { + readonly exitCode?: number; + readonly stderr?: ReadonlyArray; + readonly dockerMissing?: boolean; + } = {}, +) { + const calls: Array<{ + readonly command: string; + readonly args: ReadonlyArray; + readonly env: Readonly> | undefined; + readonly extendEnv: boolean | undefined; + }> = []; + const encoder = new TextEncoder(); + + const layer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const isStandard = command._tag === "StandardCommand"; + const cmd = isStandard ? command.command : ""; + const args = isStandard ? command.args : []; + const options = isStandard ? command.options : undefined; + calls.push({ command: cmd, args, env: options?.env, extendEnv: options?.extendEnv }); + + if (opts.dockerMissing === true && cmd === "docker") { + return yield* Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }), + ); + } + + const exitDeferred = yield* Deferred.make(); + yield* Effect.forkDetach( + Effect.gen(function* () { + yield* Effect.sleep("5 millis"); + yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); + }), + ); + const stderrBytes = (opts.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(4000 + calls.length), + stdout: Stream.empty, + stderr: Stream.fromIterable(stderrBytes), + all: Stream.empty, + exitCode: Deferred.await(exitDeferred), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), + ); + + return { + layer, + get calls() { + return calls; + }, + }; +} + type BranchConfig = typeof V1GetABranchConfigOutput.Type; type LoginRole = typeof V1CreateLoginRoleOutput.Type; type PoolerConfig = typeof V1GetPoolerConfigOutput.Type; @@ -226,16 +285,9 @@ function setup( readonly format?: "text" | "json" | "stream-json"; readonly goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; readonly projectTypes?: string; - readonly childStdout?: ReadonlyArray; - readonly childStderr?: ReadonlyArray; readonly childExitCode?: number; - readonly childLayer?: Layer.Layer; - readonly debug?: boolean; - readonly networkId?: Option.Option; - readonly onSpawn?: (record: { - readonly command: string; - readonly args: ReadonlyArray; - }) => void; + readonly childStderr?: ReadonlyArray; + readonly childDockerMissing?: boolean; readonly args?: ReadonlyArray; readonly generateTypescriptTypes?: (input: { readonly ref: string; @@ -257,7 +309,8 @@ function setup( ) => Effect.Effect; readonly poolerFallback?: Option.Option; readonly poolerFallbackFails?: boolean; - readonly sslProbeLayer?: Layer.Layer; + readonly generator?: ReturnType; + readonly generatorOutput?: string; } = {}, ) { const workdir = opts.workdir ?? mkdtempSync(join(tmpdir(), "supabase-gen-types-")); @@ -275,13 +328,12 @@ function setup( poolerFallback: opts.poolerFallback, poolerFallbackFails: opts.poolerFallbackFails, }); - const processControl = mockProcessControl(); - const child = mockChildProcessSpawner({ - stdout: [...(opts.childStdout ?? [])], - stderr: [...(opts.childStderr ?? [])], + const child = mockInspectSpawner({ exitCode: opts.childExitCode ?? 0, - onSpawn: opts.onSpawn, + stderr: opts.childStderr, + dockerMissing: opts.childDockerMissing, }); + const generator = opts.generator ?? mockGenTypesGenerator({ output: opts.generatorOutput }); const api = mockCommandPlatformApiService({ v1: { getABranchConfig: @@ -369,19 +421,15 @@ function setup( const layer = Layer.mergeAll( runtime, BunServices.layer, - opts.childLayer ?? child.layer, - processControl.layer, + child.layer, Stdio.layerTest({ args: Effect.succeed(opts.args ?? ["gen", "types"]) }), Layer.succeed(OutputFlag, opts.goOutput ?? Option.none()), - Layer.succeed(DebugFlag, opts.debug ?? false), Layer.succeed(DnsResolverFlag, "native" as const), - Layer.succeed(NetworkIdFlag, opts.networkId ?? Option.none()), - opts.sslProbeLayer ?? - pgDeltaSslProbeLayer.pipe(Layer.provide(Layer.succeed(DebugFlag, opts.debug ?? false))), Layer.succeed(CommandPlatformApiFactory, { make: CommandPlatformApi.pipe(Effect.provide(api.layer)), }), dbConfig.layer, + generator.layer, ); return { @@ -390,187 +438,13 @@ function setup( telemetry, linkedProjectCache, dbConfig, - processControl, child, api, + generator, layer, }; } -function mockSequentialChildProcessSpawner( - steps: ReadonlyArray<{ - readonly exitCode?: number; - readonly stdout?: ReadonlyArray; - readonly stderr?: ReadonlyArray; - }>, - onSpawn?: (record: { - readonly command: string; - readonly args: ReadonlyArray; - readonly options: ChildProcess.CommandOptions; - }) => void, -) { - const encoder = new TextEncoder(); - const spawned: Array<{ command: string; args: ReadonlyArray }> = []; - let stepIndex = 0; - - const layer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const cmd = command._tag === "StandardCommand" ? command.command : ""; - const args = command._tag === "StandardCommand" ? command.args : []; - spawned.push({ command: cmd, args }); - if (command._tag === "StandardCommand") - onSpawn?.({ command: cmd, args, options: command.options }); - - const step = steps[Math.min(stepIndex, steps.length - 1)]; - stepIndex += 1; - const exitDeferred = yield* Deferred.make(); - - yield* Effect.forkDetach( - Effect.gen(function* () { - yield* Effect.sleep("10 millis"); - yield* Deferred.succeed( - exitDeferred, - ChildProcessSpawner.ExitCode(step?.exitCode ?? 0), - ); - }), - ); - - const stdoutBytes = (step?.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); - const stderrBytes = (step?.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(2000 + spawned.length), - stdout: Stream.fromIterable(stdoutBytes), - stderr: Stream.fromIterable(stderrBytes), - all: Stream.empty, - exitCode: Deferred.await(exitDeferred), - isRunning: Effect.succeed(false), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ); - - return { - layer, - get spawned() { - return spawned; - }, - }; -} - -function mockDockerMissingChildProcessSpawner( - steps: ReadonlyArray<{ - readonly exitCode?: number; - readonly stdout?: ReadonlyArray; - readonly stderr?: ReadonlyArray; - }>, -) { - const encoder = new TextEncoder(); - const spawned: Array<{ command: string; args: ReadonlyArray }> = []; - let stepIndex = 0; - - const layer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.gen(function* () { - const cmd = command._tag === "StandardCommand" ? command.command : ""; - const args = command._tag === "StandardCommand" ? command.args : []; - spawned.push({ command: cmd, args }); - - if (cmd === "docker") { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "docker not found", - }), - ); - } - - const step = steps[Math.min(stepIndex, steps.length - 1)]; - stepIndex += 1; - const exitDeferred = yield* Deferred.make(); - - yield* Effect.forkDetach( - Effect.gen(function* () { - yield* Effect.sleep("10 millis"); - yield* Deferred.succeed( - exitDeferred, - ChildProcessSpawner.ExitCode(step?.exitCode ?? 0), - ); - }), - ); - - const stdoutBytes = (step?.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); - const stderrBytes = (step?.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(3000 + spawned.length), - stdout: Stream.fromIterable(stdoutBytes), - stderr: Stream.fromIterable(stderrBytes), - all: Stream.empty, - exitCode: Deferred.await(exitDeferred), - isRunning: Effect.succeed(false), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), - ), - ); - - return { - layer, - get spawned() { - return spawned; - }, - }; -} - -async function withSslProbeServer( - run: (port: number) => Promise, - response: "N" | "S" = "N", - options: { readonly host?: string; readonly port?: number } = {}, -): Promise { - const host = options.host ?? "127.0.0.1"; - const port = options.port ?? 0; - const server = createServer((socket) => { - socket.once("data", () => { - socket.write(Buffer.from(response)); - socket.end(); - }); - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, host, () => resolve()); - }); - - const address = server.address(); - if (address === null || typeof address === "string") { - server.close(); - throw new Error("failed to bind ssl probe server"); - } - - try { - return await run(address.port); - } finally { - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } -} - const nonTypescriptProjectRefScenarios = [ { lang: "go", stdout: "type PublicMovies struct {}" }, { lang: "swift", stdout: "struct PublicMovies: Codable {}" }, @@ -580,11 +454,6 @@ const nonTypescriptProjectRefScenarios = [ readonly stdout: string; }>; -const testRoot = Command.make("supabase").pipe( - Command.withSubcommands([genCommand]), - Command.withGlobalFlags(GLOBAL_FLAGS), -); - describe("gen types", () => { it.effect("accepts Go-style microsecond duration aliases", () => Effect.gen(function* () { @@ -593,85 +462,6 @@ describe("gen types", () => { }), ); - it.live("runs tokenless local generation through command wiring", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-command-local-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - const out = mockOutput({ format: "text", interactive: false }); - const analytics = mockAnalytics(); - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { exitCode: 0 }, - { exitCode: 0, stdout: ["export type Database = {};"] }, - ]); - const args = [ - "gen", - "types", - "typescript", - "--local", - "--schema", - "public", - "--workdir", - workdir, - ]; - const layer = Layer.mergeAll( - BunServices.layer, - CliOutput.layer(textCliOutputFormatter()), - out.layer, - analytics.layer, - processControlLayer, - processEnvLayer({ SUPABASE_HOME: workdir }), - mockRuntimeInfo({ cwd: workdir, homeDir: workdir }), - mockTty({ stdinIsTty: false, stdoutIsTty: false }), - child.layer, - Stdio.layerTest({ args: Effect.succeed(args) }), - Layer.succeed( - TelemetryRuntime, - TelemetryRuntime.of({ - configDir: join(workdir, ".supabase"), - tracesDir: join(workdir, ".supabase", "traces"), - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity: makeTelemetryIdentity(undefined), - isFirstRun: false, - isTty: false, - isCi: false, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }), - ), - ); - - await Effect.runPromise( - Command.runWith(testRoot, { version: "0.0.0-test" })(args).pipe( - Effect.provide(layer), - ) as Effect.Effect, - ); - - expect(out.stdoutText).toContain("export type Database = {};"); - expect(out.stderrText).not.toContain("Access token not provided"); - expect(child.spawned).toHaveLength(3); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - it.live("generates typescript types from a project ref", () => { const { layer, out, api, linkedProjectCache, telemetry } = setup({ projectId: Option.some(VALID_REF), @@ -853,43 +643,6 @@ describe("gen types", () => { }, ); - it.live( - "--db-url --schema succeeds on an explicit --workdir with no project of its own, since an explicit schema never needs the config load", - () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); - writeConfig( - root, - ['project_id = "demo"', "", "[api]", 'schemas = ["ancestor_only"]'].join("\n"), - ); - const sub = join(root, "nested", "dir"); - mkdirSync(sub, { recursive: true }); - const { layer } = setup({ - workdir: sub, - skipConfig: true, - explicitWorkdir: true, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); - - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - it.live( "an explicit --workdir naming a directory that does not exist at all fails before any config load", () => { @@ -962,47 +715,180 @@ describe("gen types", () => { }); }); - it.live("rejects combining --local and --linked", () => { - const { layer, telemetry } = setup({ args: ["gen", "types", "--local", "--linked"] }); + it.live("generates from --project-id without a local project config", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-pid-no-config-")); + const { layer, api } = setup({ workdir, skipConfig: true, projectTypes: "ok" }); return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( + yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).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 [local linked project-id db-url] are set none of the others can be; [linked local] were all set", - ); - } - expect(telemetry.flushed).toBe(true); + expect(api.requests[0]).toEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public" }, + }); }); }); - it.live("does not misdetect a mutex flag consumed as -s's value (pflag consumption)", () => { - // `childExitCode: 1` fails the local target's `container inspect`, keeping the - // downstream failure deterministic before the real SSL probe can reach whatever - // is listening on the local db port. - const { layer } = setup({ - args: ["gen", "types", "-s", "--linked", "--local"], - childExitCode: 1, + it.live("resolves the linked fallback without a local project config", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-fallback-no-config-")); + const { layer, api } = setup({ + workdir, + skipConfig: true, + projectId: Option.some(VALID_REF), + projectTypes: "ok", }); return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("failed to inspect service"); - expect(String(exit.cause)).not.toContain("if any flags in the group"); - } - }); - }); + yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + + expect(api.requests[0]).toEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public" }, + }); + }); + }); + + it.live("ignores positional language scanning when argv lacks the gen types context", () => { + const { layer, api } = setup({ + args: ["unrelated", "argv"], + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); + + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + Effect.provide(layer), + ); + + expect(api.requests).toHaveLength(1); + }); + }); + + it.live("prefers explicit --schema on the linked path", () => { + const { layer, api } = setup({ + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); + + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ linked: true, schema: ["auth"] })).pipe(Effect.provide(layer)); + expect(api.requests[0]).toEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "auth" }, + }); + }); + }); + + it.live("prefers explicit --schema on the linked fallback path", () => { + const { layer, api } = setup({ + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); + + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ schema: ["auth"] })).pipe(Effect.provide(layer)); + expect(api.requests[0]).toEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "auth" }, + }); + }); + }); + + it.live("silently ignores --query-timeout for implicit linked TypeScript generation", () => { + const { layer, out, api } = setup({ + args: ["gen", "types", "--query-timeout", "20s"], + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); + + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ queryTimeout: "20s" })).pipe(Effect.provide(layer)); + + expect(out.stderrText).not.toContain("--query-timeout"); + expect(api.requests).toContainEqual({ + method: "generateTypescriptTypes", + input: { ref: VALID_REF, included_schemas: "public" }, + }); + }); + }); + + it.live("maps project type generation network failures", () => { + const { layer } = setup({ + generateTypescriptTypes: () => Effect.fail(new Error("network error")), + }); + + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "failed to get typescript types: Error: network error", + ); + } + }); + }); + + it.live("accepts legacy positional typescript without changing behavior", () => { + const { layer } = setup({ + args: ["gen", "types", "typescript"], + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); + + return Effect.gen(function* () { + yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + }); + }); + + // --- Flag mutex groups and argv-scan precedence ----------------------------------------- + + it.live("rejects combining --local and --linked", () => { + const { layer, telemetry } = setup({ args: ["gen", "types", "--local", "--linked"] }); + + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true, linked: 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 [local linked project-id db-url] are set none of the others can be; [linked local] were all set", + ); + } + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("does not misdetect a mutex flag consumed as -s's value (pflag consumption)", () => { + // `childExitCode: 1` fails the local target's `container inspect`, keeping the + // downstream failure deterministic once `--linked` is consumed as `-s`'s value. + const { layer } = setup({ + args: ["gen", "types", "-s", "--linked", "--local"], + childExitCode: 1, + }); + + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("failed to inspect service"); + expect(String(exit.cause)).not.toContain("if any flags in the group"); + } + }); + }); it.live("rejects --swift-access-control with --linked (cobra mutex group)", () => { const { layer } = setup({ @@ -1175,72 +1061,6 @@ describe("gen types", () => { }); }); - it.live("silently ignores --query-timeout for implicit linked TypeScript generation", () => { - const { layer, out, api } = setup({ - args: ["gen", "types", "--query-timeout", "20s"], - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); - - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ queryTimeout: "20s" })).pipe(Effect.provide(layer)); - - expect(out.stderrText).not.toContain("--query-timeout"); - expect(api.requests).toContainEqual({ - method: "generateTypescriptTypes", - input: { ref: VALID_REF, included_schemas: "public" }, - }); - }); - }); - - it.live( - "forwards --query-timeout and --swift-access-control to pg-meta for implicit linked non-TypeScript generation", - () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, dbConfig } = setup({ - args: [ - "gen", - "types", - "--lang", - "go", - "--query-timeout", - "20s", - "--swift-access-control", - "public", - ], - projectId: Option.some(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( - genTypes( - defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), - ).pipe(Effect.provide(layer)), - ); - - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); - expect(docker.env.has("PG_QUERY_TIMEOUT_SECS=20")).toBe(true); - expect(docker.env.has("PG_CONN_TIMEOUT_SECS=20")).toBe(true); - expect(docker.env.has("PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=public")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - it.live("prefers the --postgrest-v9-compat guard over mutex group errors", () => { const { layer } = setup({ args: ["gen", "types", "--local", "--linked", "--postgrest-v9-compat"], @@ -1302,1218 +1122,448 @@ describe("gen types", () => { }); }); - it.live("allows --swift-access-control for local non-Swift generation", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-swift-flag-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); + it.live("rejects a non-typescript language passed after a -- separator", () => { + const { layer } = setup({ args: ["gen", "types", "--", "go"] }); - const { layer } = setup({ - workdir, - args: [ - "gen", - "types", - "--local", - "--lang", - "python", - "--swift-access-control", - "public", - ], - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), - ).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - expect(docker.env.has("PG_META_GENERATE_TYPES=python")).toBe(true); - expect(docker.env.has("PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=public")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); + }); - it.live("allows --postgrest-v9-compat together with --db-url", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - args: [ - "gen", - "types", - "--db-url", - `postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, - "--postgrest-v9-compat", - ], - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - postgrestV9Compat: true, - }), - ).pipe(Effect.provide(layer)), - ); + it.live("treats a trailing -- with no operand as no positional language", () => { + const { layer, api } = setup({ + args: ["gen", "types", "--"], + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); - 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))), - }), - ); + return Effect.gen(function* () { + yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + expect(api.requests).toHaveLength(1); + }); + }); - 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", VALID_REF], - childStdout: [scenario.stdout], - dbConfigResolve: (input) => - Effect.succeed( - remoteResolvedConfig( - { - host: "127.0.0.1", - port, - user: `cli_login_${VALID_REF}`, - password: "temporary-password", - database: "postgres", - }, - (input.linkedProjectRef !== undefined - ? Option.getOrUndefined(input.linkedProjectRef) - : undefined) ?? 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( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: scenario.lang, - }), - ).pipe(Effect.provide(layer)), - ); - - expect(api.requests).toContainEqual({ - method: "getProject", - input: { ref: 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[1]?.args).toContain("--network"); - expect(child.spawned[1]?.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_${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"); - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); - const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; - expect( - linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, - ).toBe(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("treats a positional after a valueless long flag as the language", () => { + const { layer } = setup({ args: ["gen", "types", "--local", "go"] }); - 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(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( - genTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); + }); - expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("linked"); - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + it.live("treats a positional after a valueless short flag as the language", () => { + const { layer } = setup({ args: ["gen", "types", "-x", "go"] }); - 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", VALID_REF], - childStdout: ["type PublicMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - options: `reference=${VALID_REF}`, - }), - ), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); + }); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres.${VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10&options=reference%3D${VALID_REF}`, - ), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + it.live("rejects legacy positional non-typescript without an explicit lang flag", () => { + const { layer } = setup({ + args: ["gen", "types", "go"], + }); - it.live("resolves the pg-meta image through the shared resolver before running it", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const image = await Effect.runPromise(resolvePgmetaImage()); - const candidates = await Effect.runPromise(getRegistryImageUrlCandidates(image)); - const child = mockSequentialChildProcessSpawner([ - ...candidates.map(() => ({ - exitCode: 1, - stderr: ["Error response from daemon: No such image"], - })), - { exitCode: 0 }, - { exitCode: 0, stdout: ["type PulledThenRun struct {}"] }, - ]); - const { layer, out } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - }); - - await Effect.runPromise( - genTypes(defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" })).pipe( - Effect.provide(layer), - ), - ); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - expect(out.stdoutText).toContain("type PulledThenRun struct {}"); - expect(child.spawned.map((spawn) => spawn.args)).toEqual([ - ...candidates.map((candidate) => ["image", "inspect", candidate]), - ["pull", image], - expect.arrayContaining(["run", image, "node", "dist/server/server.js"]), - ]); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); + }); - it.live("surfaces an image resolution failure without retrying through the pooler", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: [ - 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname', - ], - }, - ]); - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "127.0.0.1", - port, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); + it.live( + "rejects legacy positional non-typescript after consuming short flags with values", + () => { + const { layer } = setup({ + args: ["gen", "types", "-o", "json", "go"], + goOutput: Option.some("json"), + }); - const exit = await Effect.runPromiseExit( - genTypes(defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" })).pipe( - Effect.provide(layer), - ), - ); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); - expect(dbConfig.poolerFallbacks).toHaveLength(0); - expect(child.spawned.map((spawn) => spawn.args[0])).not.toContain("run"); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); + }, ); - it.live("retries remote pg-meta through the IPv4 pooler on a container IPv6 failure", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { - exitCode: 1, - stderr: [ - 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname', - ], - }, - { exitCode: 0, stdout: ["type RetriedViaPooler struct {}"] }, - ]); - const poolerConn: PgConnInput = { - host: "127.0.0.1", - port, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }; - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some(poolerConn), - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); + // --- --network-id is a hard error on every natively-generated path --------------------- - expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); - expect(out.stderrText).toContain("does not support IPv6"); - expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); - expect(child.spawned).toHaveLength(3); - expect( - dockerEnv(child.spawned[1]?.args ?? []).has( - `PG_META_DB_URL=postgresql://postgres:direct-password@db.${VALID_REF}.supabase.co:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); - expect( - dockerEnv(child.spawned[2]?.args ?? []).has( - `PG_META_DB_URL=postgresql://postgres.${VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); - expect(dbConfig.poolerFallbacks).toHaveLength(1); - expect(dbConfig.poolerFallbacks[0]?.connType).toBe("linked"); - expect(dbConfig.poolerFallbacks[0]?.adHocProjectRef).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + it.live("rejects --network-id after the gen types command path", () => { + const { layer, generator, child } = setup({ + args: ["gen", "types", "--local", "--network-id", "net"], + }); - it.live("retries remote pg-meta through the IPv4 pooler on Node ENETUNREACH stderr", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { - exitCode: 1, - stderr: ["connect ENETUNREACH 2600:1f18::1:5432 - Local (:::0)"], - }, - { exitCode: 0, stdout: ["type RetriedViaPooler struct {}"] }, - ]); - const poolerConn: PgConnInput = { - host: "127.0.0.1", - port, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }; - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some(poolerConn), - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); - expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); - expect(child.spawned).toHaveLength(3); - expect(dbConfig.poolerFallbacks).toHaveLength(1); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("GenTypesNetworkIdUnsupportedError"); + expect(String(exit.cause)).toContain("cannot join a Docker network via --network-id"); + } + expect(generator.calls).toHaveLength(0); + expect(child.calls).toHaveLength(0); + }); + }); - it.live("does not retry remote pg-meta when the container failure is not IPv6", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { exitCode: 1, stderr: ["permission denied for schema public"] }, - ]); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "127.0.0.1", - port, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); + it.live( + "rejects a persistent --network-id set before the command path (supabase --network-id net gen types --local)", + () => { + const { layer, generator, child } = setup({ + args: ["--network-id", "net", "gen", "types", "--local"], + }); - const exit = await Effect.runPromise( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(2); - expect(dbConfig.poolerFallbacks).toHaveLength(0); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("GenTypesNetworkIdUnsupportedError"); + expect(String(exit.cause)).toContain("cannot join a Docker network via --network-id"); + } + expect(generator.calls).toHaveLength(0); + expect(child.calls).toHaveLength(0); + }); + }, ); - it.live( - "does not run pooler fallback a second time when the retry also exits with IPv6 stderr", - () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, + // --- Non-TypeScript generation through the DB resolver + native generator -------------- + + for (const scenario of nonTypescriptProjectRefScenarios) { + it.live(`generates ${scenario.lang} types from a project ref through the DB resolver`, () => { + const { layer, out, api, linkedProjectCache, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", scenario.lang, "--project-id", VALID_REF], + generatorOutput: scenario.stdout, + dbConfigResolve: (input) => + Effect.succeed( + remoteResolvedConfig( { - exitCode: 1, - stderr: [ - `could not translate host name "db.${VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - ]); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ host: "127.0.0.1", - port, - user: `postgres.${VALID_REF}`, - password: "pooler-password", + port: 5432, + user: `cli_login_${VALID_REF}`, + password: "temporary-password", database: "postgres", - }), - }); - - const exit = await Effect.runPromise( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(3); - expect(dbConfig.poolerFallbacks).toHaveLength(1); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - - it.live( - "does not retry remote pg-meta when the resolved connection is already a pooler host", - () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - ]); - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 5432, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 5432, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); - - const exit = yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(2); - expect(dbConfig.poolerFallbacks).toHaveLength(0); - expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); - }), + }, + (input.linkedProjectRef !== undefined + ? Option.getOrUndefined(input.linkedProjectRef) + : undefined) ?? VALID_REF, + ), ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + getABranchConfig: ({ branch_id_or_ref }) => + Effect.fail(new Error(`unexpected preview branch lookup for ${branch_id_or_ref}`)), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + }); - it.live("retries remote pg-meta when the TLS probe fails with ENETUNREACH", () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - let probeCalls = 0; - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { exitCode: 0, stdout: ["type RetriedAfterProbeFailure struct {}"] }, - ]); - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => - Effect.gen(function* () { - probeCalls += 1; - if (probeCalls === 1) { - return yield* Effect.fail( - new PgDeltaSslProbeError({ - message: "network is unreachable", - cause: Object.assign(new Error(), { code: "ENETUNREACH" }), - }), - ); - } - return false; - }), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port: 5432, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 5432, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: scenario.lang, + }), + ).pipe(Effect.provide(layer)); - yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)); + expect(api.requests).toContainEqual({ method: "getProject", input: { ref: 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(out.stderrText).toContain("Connecting to 127.0.0.1 5432"); + expect(out.stdoutText).toContain(scenario.stdout); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); + const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; + expect( + linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, + ).toBe(VALID_REF); + expect(generator.calls).toHaveLength(1); + const call = generator.calls[0]; + expect(call?.lang).toBe(scenario.lang); + expect(call?.includedSchemas).toEqual(["public"]); + expect(call?.isLocal).toBe(false); + // project-ref generation always pins the Supabase CA, promoting sslmode to verify-ca. + expect(call?.conn.sslmode).toBe("require"); + expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); + expect(linkedProjectCache.cached).toBe(true); + }); + }); + } - expect(out.stdoutText).toContain("type RetriedAfterProbeFailure struct {}"); - expect(probeCalls).toBe(2); - expect(child.spawned).toHaveLength(2); - expect(dbConfig.poolerFallbacks).toHaveLength(1); + it.live("resolves the linked workdir DB without ad-hoc project-ref semantics", () => { + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--linked"], + projectId: Option.some(VALID_REF), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port: 5432, + user: "postgres", + password: "workdir-password", + database: "postgres", }), ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + }); - it.live("does not retry remote pg-meta when the TLS probe fails with ECONNREFUSED", () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0, stdout: ["should not spawn"] }, - ]); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => - Effect.fail( - new PgDeltaSslProbeError({ - message: "connection refused", - cause: Object.assign(new Error(), { code: "ECONNREFUSED" }), - }), - ), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port: 5432, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 5432, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)); - const exit = yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + }); + }); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(0); - expect(dbConfig.poolerFallbacks).toHaveLength(0); + it.live("preserves resolver connection options for remote non-TypeScript typegen", () => { + const { layer, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + options: `reference=${VALID_REF}`, }), ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - - it.live("preserves the original remote pg-meta error when pooler fallback resolution fails", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { - exitCode: 1, - stderr: [ - 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname', - ], - }, - ]); - const { layer } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallbackFails: true, - }); - - const exit = await Effect.runPromise( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("error running container: exit 1"); - expect(String(exit.cause)).not.toContain("pooler fallback failed"); - } - expect(child.spawned).toHaveLength(2); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "go", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + ).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.conn.options).toBe(`reference=${VALID_REF}`); + expect(generator.calls[0]?.conn.user).toBe(`postgres.${VALID_REF}`); + }); + }); + + it.live( + "forwards --query-timeout and --swift-access-control to the generator for implicit linked non-TypeScript generation", + () => { + const { layer, dbConfig, generator } = setup({ + args: [ + "gen", + "types", + "--lang", + "go", + "--query-timeout", + "20s", + "--swift-access-control", + "public", + ], + projectId: Option.some(VALID_REF), + }); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), + ).pipe(Effect.provide(layer)); + + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + const call = generator.calls[0]; + expect(call?.swiftAccessControl).toBe("public"); + expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); + expect(call?.conn.connectTimeoutSeconds).toBe(20); + }); + }, ); - it.live("uses remote config schemas for explicit project-ref pg-meta typegen", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-remote-config-")); - writeConfig( - workdir, - [ - 'project_id = "base"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[remotes.staging]", - `project_id = "${VALID_REF}"`, - "", - "[remotes.staging.api]", - 'schemas = ["private"]', - "", - ].join("\n"), - ); - const docker = captureDockerRun(); - const { layer } = setup({ - workdir, - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - childStdout: ["type PrivateMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - onSpawn: docker.onSpawn, - }); - - try { - await Effect.runPromise( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private")).toBe( - true, - ); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(false); + it.live("uses remote config schemas for explicit project-ref typegen", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-remote-config-")); + writeConfig( + workdir, + [ + 'project_id = "base"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[remotes.staging]", + `project_id = "${VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const { layer, generator } = setup({ + workdir, + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + }); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "go", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); - it.live("uses remote config schemas for linked pg-meta typegen", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-linked-config-")); - writeConfig( - workdir, - [ - 'project_id = "base"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[remotes.staging]", - `project_id = "${VALID_REF}"`, - "", - "[remotes.staging.api]", - 'schemas = ["private"]', - "", - ].join("\n"), - ); - const docker = captureDockerRun(); - const { layer } = setup({ - workdir, - projectId: Option.some(VALID_REF), - args: ["gen", "types", "--lang", "go", "--linked"], - childStdout: ["type PrivateMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - onSpawn: docker.onSpawn, - }); - - try { - await Effect.runPromise( - genTypes( - defaultFlags({ - linked: true, - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private")).toBe( - true, - ); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(false); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private"]); + }); + }); + + it.live("uses remote config schemas for linked typegen", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-linked-config-")); + writeConfig( + workdir, + [ + 'project_id = "base"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[remotes.staging]", + `project_id = "${VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const { layer, generator } = setup({ + workdir, + projectId: Option.some(VALID_REF), + args: ["gen", "types", "--lang", "go", "--linked"], + }); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + linked: true, + lang: "go", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); - 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", 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( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)), - ); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private"]); + }); + }); - expect(api.requests).toContainEqual({ - method: "getProject", - input: { ref: VALID_REF }, - }); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: 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); + // --- Preview-branch fallback ------------------------------------------------------------- + + it.live("falls back to preview branch config for non-TypeScript project refs", () => { + const { layer, api, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + generatorOutput: "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: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + }); - it.live("retries preview branch pg-meta through the branch IPv4 pooler", () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - const poolerHost = "aws-0-us-east-1.pooler.supabase.com"; - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - { exitCode: 0, stdout: ["class RetriedViaBranchPooler(BaseModel):"] }, - ]); - const { layer, api } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - 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: `db.${branch_id_or_ref}.supabase.co`, - db_port: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - getPoolerConfig: ({ ref }) => - Effect.succeed([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - }); - - yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)); - - expect(api.requests).toContainEqual({ - method: "getPoolerConfig", - input: { ref: VALID_REF }, - }); - expect(child.spawned).toHaveLength(3); - expect( - dockerEnv(child.spawned[2]?.args ?? []).has( - `PG_META_DB_URL=postgresql://postgres.${VALID_REF}:branch-password@${poolerHost}:5432/postgres?connect_timeout=10`, - ), - ).toBe(true); - }), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); - it.live("skips preview branch pooler fallback when the pooler URL fails validation", () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - ]); - const { layer, api } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - 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: `db.${branch_id_or_ref}.supabase.co`, - db_port: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - getPoolerConfig: ({ ref }) => - Effect.succeed([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - }); - - const exit = yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - expect(api.requests).toContainEqual({ - method: "getPoolerConfig", - input: { ref: VALID_REF }, - }); - expect(child.spawned).toHaveLength(2); - }), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(api.requests).toContainEqual({ method: "getProject", input: { ref: VALID_REF } }); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(dbConfig.resolves).toHaveLength(0); + const call = generator.calls[0]; + expect(call?.conn.host).toBe("127.0.0.1"); + expect(call?.conn.user).toBe("branch_user"); + expect(call?.conn.password).toBe("branch-password"); + // Preview-branch generation pins the Supabase CA the same as any other remote target. + expect(call?.conn.sslmode).toBe("require"); + expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); + }); + }); - 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", VALID_REF], - childStdout: ["class PublicMovies(BaseModel):"], - 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( - genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)), - ); + it.live("falls back to preview branch config for any project 404 body", () => { + const { layer, api, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + generatorOutput: "class PublicMovies(BaseModel):", + 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: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + }); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: 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); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "python", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: VALID_REF }, + }); + expect(dbConfig.resolves).toHaveLength(0); + expect(generator.calls[0]?.conn.password).toBe("branch-password"); + }); + }); it.live("fails clearly when preview branch config does not include DB credentials", () => { const { layer } = setup({ @@ -2547,701 +1597,684 @@ describe("gen types", () => { }); }); - it.live("maps project type generation network failures", () => { - const { layer } = setup({ - generateTypescriptTypes: () => Effect.fail(new Error("network error")), + // --- Pooler fallback on an IPv6-classified generation failure --------------------------- + + it.live("retries through the IPv4 pooler on an IPv6-classified generation failure", () => { + const poolerConn: PgConnInput = { + host: "127.0.0.1", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }; + const generator = sequentialGenerator([ + () => Effect.fail(ipv6Failure()), + () => Effect.succeed("type RetriedViaPooler struct {}"), + ]); + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some(poolerConn), }); return Effect.gen(function* () { - const exit = yield* genTypes( + yield* genTypes( defaultFlags({ projectId: Option.some(VALID_REF), + lang: "go", }), - ).pipe(Effect.provide(layer), Effect.exit); + ).pipe(Effect.provide(layer)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "failed to get typescript types: Error: network error", - ); - } + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(out.stderrText).toContain("does not support IPv6"); + expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); + expect(generator.calls).toHaveLength(2); + expect(generator.calls[0]?.conn.host).toBe(`db.${VALID_REF}.supabase.co`); + expect(generator.calls[1]?.conn.host).toBe("127.0.0.1"); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + expect(dbConfig.poolerFallbacks[0]?.connType).toBe("linked"); + expect(dbConfig.poolerFallbacks[0]?.adHocProjectRef).toBe(true); }); }); - it.live("spawns pg-meta for local generation and forwards child output", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - "port = 54321", - 'schemas = ["public", "custom"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - writeFileSync( - join(workdir, "supabase", ".env"), - "DOCKER_HOST=project-daemon\nSUPABASE_INTERNAL_IMAGE_REGISTRY=docker.io\nSUPABASE_USE_SLIM_IMAGES=1\nSUPABASE_DB_PASSWORD=dotenv-password\n", - ); + it.live("does not retry through the pooler when the failure is not IPv6-classified", () => { + const generator = sequentialGenerator([() => Effect.fail(nonIpv6Failure())]); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "127.0.0.1", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); - const childCalls: Array<{ - readonly command: string; - readonly args: ReadonlyArray; - readonly options: ChildProcess.CommandOptions; - }> = []; - const child = mockSequentialChildProcessSpawner( - [{}, {}, { stdout: ["export type Database = {};"], stderr: ["pg-meta warning"] }], - (record) => childCalls.push(record), - ); - const { layer, out, linkedProjectCache } = setup({ - workdir, - childLayer: child.layer, - }); - const configProvider = ConfigProvider.fromEnvRecord({}, { preserveEmptyStrings: true }); - const expectedSlimImage = toSlimImage("pgmeta", dockerfileServiceImageRaw("pgmeta")); - - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.provideService(ConfigProvider.ConfigProvider, configProvider), - ), - ); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + ).pipe(Effect.provide(layer), Effect.exit); - expect(out.stderrText).toContain("Connecting to db 5432"); - expect(out.stderrText).toContain("pg-meta warning"); - expect(out.stdoutText).toContain("export type Database = {};"); - expect(child.spawned).toHaveLength(3); - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", "supabase_db_demo"], - }); - expect(child.spawned[2]?.command).toBe("docker"); - expect(child.spawned[2]?.args).toContain("--network"); - expect(child.spawned[2]?.args).toContain("supabase_network_demo"); - expect( - dockerEnv(child.spawned[2]?.args ?? []).has( - "PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,custom", - ), - ).toBe(true); - expect(child.spawned[2]?.args).toContain(expectedSlimImage); - expect(child.spawned[2]?.args.slice(-2)).toEqual(["node", "dist/server/server.js"]); - expect(childCalls[0]?.options.env).toEqual({ - DOCKER_HOST: "project-daemon", - SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", - SUPABASE_USE_SLIM_IMAGES: "1", - }); - expect(childCalls[0]?.options.extendEnv).toBe(true); - expect(childCalls[1]?.options.env).toEqual({ - DOCKER_HOST: "project-daemon", - SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", - SUPABASE_USE_SLIM_IMAGES: "1", - }); - expect(childCalls[1]?.options.extendEnv).toBe(true); - expect(childCalls[2]?.options.env).toEqual({ - DOCKER_HOST: "project-daemon", - SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", - SUPABASE_USE_SLIM_IMAGES: "1", - }); - expect(childCalls[2]?.options.extendEnv).toBe(true); - expect(linkedProjectCache.cached).toBe(false); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + }); + }); - it.live("connects pg-meta to the stack local database over host networking", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-stack-local-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public", "custom"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); + it.live("does not run pooler fallback a second time when the retry also fails IPv6-style", () => { + const generator = sequentialGenerator([ + () => Effect.fail(ipv6Failure()), + () => Effect.fail(ipv6Failure()), + ]); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "127.0.0.1", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); - const { layer, out, child } = setup({ - workdir, - childStdout: ["export type Database = {};"], - onSpawn: docker.onSpawn, - dbConfigResolve: () => - Effect.succeed( - localResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "postgres", - database: "postgres", - }), - ), - }); - - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"))), - ), - ); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + ).pipe(Effect.provide(layer), Effect.exit); - expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); - expect(out.stderrText).not.toContain("Connecting to db 5432"); - expect(child.spawned.some((spawn) => spawn.args.includes("supabase_db_demo"))).toBe( - false, - ); - expect( - child.spawned.some( - (spawn) => spawn.args.includes("--network") && spawn.args.includes("host"), - ), - ).toBe(true); - expect(child.spawned.some((spawn) => spawn.args.includes("supabase_network_demo"))).toBe( - false, - ); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }); + }); - it.live("keeps loopback when Linux stack gen types uses --network-id host", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-stack-host-net-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); + it.live( + "does not retry through the pooler when the resolved connection is already a pooler host", + () => { + const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); - const { layer, child } = setup({ - workdir, - childStdout: ["export type Database = {};"], - networkId: Option.some("host"), - onSpawn: docker.onSpawn, - dbConfigResolve: () => - Effect.succeed( - localResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "postgres", - database: "postgres", - }), - ), - }); - - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"))), - ), - ); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + ).pipe(Effect.provide(layer), Effect.exit); - expect( - child.spawned.some( - (spawn) => spawn.args.includes("--network") && spawn.args.includes("host"), - ), - ).toBe(true); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres:postgres@127.0.0.1:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); - expect(docker.env.entries.some((entry) => entry.includes("host.docker.internal"))).toBe( - false, - ); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); + }); + }, ); - it.live("adds host-gateway for Linux stack gen types on a named --network-id", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-stack-named-net-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); + it.live("preserves the original generation error when pooler fallback resolution fails", () => { + const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); + const { layer } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallbackFails: true, + }); - const { layer, child } = setup({ - workdir, - childStdout: ["export type Database = {};"], - networkId: Option.some("custom-network"), - onSpawn: docker.onSpawn, - dbConfigResolve: () => - Effect.succeed( - localResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "postgres", - database: "postgres", - }), - ), - }); - - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"))), - ), - ); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + ).pipe(Effect.provide(layer), Effect.exit); - const pgmeta = child.spawned.find((spawn) => spawn.args.includes("custom-network")); - expect(pgmeta?.args).toContain("custom-network"); - expect(pgmeta?.args).toContain("--add-host"); - expect(pgmeta?.args).toContain("host.docker.internal:host-gateway"); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres:postgres@host.docker.internal:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("No address associated with hostname"); + expect(String(exit.cause)).not.toContain("pooler fallback failed"); + } + expect(generator.calls).toHaveLength(1); + }); + }); - it.live("falls back to podman when the docker executable is missing for local generation", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - const child = mockDockerMissingChildProcessSpawner([ - { exitCode: 0 }, - { exitCode: 0 }, - { exitCode: 0, stdout: ["export type Database = {};"] }, - ]); - const { layer, out } = setup({ - workdir, - childLayer: child.layer, - }); - - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + it.live("retries preview branch generation through the branch IPv4 pooler", () => { + const poolerHost = "aws-0-us-east-1.pooler.supabase.com"; + const generator = sequentialGenerator([ + () => Effect.fail(ipv6Failure("python")), + () => Effect.succeed("class RetriedViaBranchPooler(BaseModel):"), + ]); + const { layer, api } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + generator, + 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: `db.${branch_id_or_ref}.supabase.co`, + db_port: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + getPoolerConfig: ({ ref }) => + Effect.succeed([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + }); - expect(out.stdoutText).toContain("export type Database = {};"); - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", "supabase_db_demo"], - }); - expect(child.spawned[1]).toEqual({ - command: "podman", - args: ["container", "inspect", "supabase_db_demo"], - }); - expect(child.spawned[2]).toEqual({ - command: "docker", - args: ["image", "inspect", Effect.runSync(resolvePgmetaImage())], - }); - expect(child.spawned[3]).toEqual({ - command: "podman", - args: ["image", "inspect", Effect.runSync(resolvePgmetaImage())], - }); - expect(child.spawned[4]?.command).toBe("docker"); - expect(child.spawned[4]?.args).toContain("run"); - expect(child.spawned[5]?.command).toBe("podman"); - expect(child.spawned[5]?.args).toContain("run"); - expect(child.spawned[5]?.args).toContain("supabase_network_demo"); - expect(child.spawned).toHaveLength(6); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "python", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); - it.live("uses sanitized local docker ids and env-backed local db passwords", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-sanitized-")); - writeConfig( - workdir, - [ - 'project_id = "..demo project with spaces"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: VALID_REF }, + }); + expect(generator.calls).toHaveLength(2); + expect(generator.calls[1]?.conn.host).toBe(poolerHost); + expect(generator.calls[1]?.conn.password).toBe("branch-password"); + }); + }); - const { layer, child } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord({ SUPABASE_DB_PASSWORD: "secret-password" }), - ), - ), - ); + it.live("skips preview branch pooler fallback when the pooler URL fails validation", () => { + const generator = sequentialGenerator([() => Effect.fail(ipv6Failure("python"))]); + const { layer, api } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + generator, + 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: `db.${branch_id_or_ref}.supabase.co`, + db_port: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + getPoolerConfig: ({ ref }) => + Effect.succeed([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + }); - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", "supabase_db_demo_project_with_spaces"], - }); - expect(child.spawned[2]?.args).toContain("supabase_network_demo_project_with_spaces"); - expect( - docker.env.has( - "PG_META_DB_URL=postgresql://postgres:secret-password@db:5432/postgres?connect_timeout=10", - ), - ).toBe(true); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "python", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer), Effect.exit); - it.live("forces v9 compat when rest-version reports v9 on a modern database", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-v9-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - "major_version = 15", - `port = ${port}`, - ].join("\n"), - ); - writeTempFile(workdir, "rest-version", "v9.0.1\n"); + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: VALID_REF }, + }); + expect(generator.calls).toHaveLength(1); + }); + }); - const { layer } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + // --- TLS: pin the Supabase CA only where the design calls for it ------------------------ - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + it.live("pins the Supabase CA for a db-url pointing at a direct database host", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ), + }); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), - ).toBe(true); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some( + `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres`, + ), }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - - it.live("ignores rest-version v9 marker on databases older than 15", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-pg14-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - "major_version = 14", - `port = ${port}`, - ].join("\n"), - ); - writeTempFile(workdir, "rest-version", "v9.0.1\n"); + ).pipe(Effect.provide(layer)); - const { layer } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + expect(generator.calls[0]?.conn.sslmode).toBe("require"); + expect(generator.calls[0]?.conn.sslrootcertInline).toBe(rootCaBundle()); + }); + }); - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + it.live("pins the Supabase CA for a db-url pointing at the pooler host", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 6543, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + ), + }); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=true"), - ).toBe(true); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some( + "postgresql://postgres.ref:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres", + ), }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - - it.live("overrides the pg-meta image version from the pgmeta-version temp file", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-pgmeta-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - writeTempFile(workdir, "pgmeta-version", "v0.99.0\n"); + ).pipe(Effect.provide(layer)); - const { layer, child } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + expect(generator.calls[0]?.conn.sslmode).toBe("require"); + expect(generator.calls[0]?.conn.sslrootcertInline).toBe(rootCaBundle()); + }); + }); - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + it.live("honors an explicit sslmode from the db-url's DSN on a Supabase host", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + sslmode: "disable", + }), + ), + }); - expect(child.spawned[2]?.args).toContain(Effect.runSync(resolvePgmetaImage("0.99.0"))); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some( + `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres?sslmode=disable`, + ), }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); - it.live("prefers explicit --schema over config schemas for local generation", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-schema-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public", "custom"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - const { layer } = setup({ workdir, childStdout: ["generated"], onSpawn: docker.onSpawn }); + expect(generator.calls[0]?.conn.sslmode).toBe("disable"); + expect(generator.calls[0]?.conn.sslrootcertInline).toBeUndefined(); + }); + }); - await Effect.runPromise( - genTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).pipe( - Effect.provide(layer), - ), - ); + it.live("leaves a non-Supabase db-url host unpinned", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "db.example.net", + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ), + }); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=auth,storage")).toBe(true); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@db.example.net:5432/postgres"), }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); - it.live("falls back to the workdir basename when config has no project_id", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-noid-")); - writeConfig( - workdir, - ["[api]", 'schemas = ["public"]', "", "[db]", `port = ${port}`].join("\n"), - ); - const { layer, child } = setup({ workdir, childStdout: ["generated"] }); + expect(generator.calls[0]?.conn.sslmode).toBeUndefined(); + expect(generator.calls[0]?.conn.sslrootcertInline).toBeUndefined(); + }); + }); - await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + it.live("never pins TLS for a local db-url target", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + localResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ), + }); - const inspectId = child.spawned[0]?.args[2] ?? ""; - expect(inspectId.startsWith("supabase_db_")).toBe(true); - expect(inspectId).not.toBe("supabase_db_demo"); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); - it.live("generates from --project-id without a local project config", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-pid-no-config-")); - const { layer, api } = setup({ workdir, skipConfig: true, projectTypes: "ok" }); + expect(generator.calls[0]?.conn.sslmode).toBeUndefined(); + expect(generator.calls[0]?.conn.sslrootcertInline).toBeUndefined(); + }); + }); - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( - Effect.provide(layer), + // --- Local generation: legacy backend (still inspects the Docker container) ------------- + + it.live( + "generates locally via the legacy backend, connecting directly to the mapped port", + () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public", "custom"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + writeFileSync( + join(workdir, "supabase", ".env"), + "DOCKER_HOST=project-daemon\nSUPABASE_INTERNAL_IMAGE_REGISTRY=docker.io\nSUPABASE_USE_SLIM_IMAGES=1\nSUPABASE_DB_PASSWORD=dotenv-password\n", ); + const { layer, out, linkedProjectCache, child, generator } = setup({ workdir }); + const configProvider = ConfigProvider.fromEnvRecord({}, { preserveEmptyStrings: true }); - expect(api.requests[0]).toEqual({ - method: "generateTypescriptTypes", - input: { ref: VALID_REF, included_schemas: "public" }, + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.provideService(ConfigProvider.ConfigProvider, configProvider), + ); + + expect(out.stderrText).toContain("Connecting to 127.0.0.1 54321"); + expect(out.stdoutText).toContain("generated"); + expect(child.calls).toHaveLength(1); + expect(child.calls[0]).toMatchObject({ + command: "docker", + args: ["container", "inspect", "supabase_db_demo"], + extendEnv: true, + }); + // Env forwarding from the config's `.env` excludes SUPABASE_DB_PASSWORD. + expect(child.calls[0]?.env).toEqual({ + DOCKER_HOST: "project-daemon", + SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", + SUPABASE_USE_SLIM_IMAGES: "1", + }); + expect(generator.calls).toHaveLength(1); + const call = generator.calls[0]; + expect(call?.conn).toEqual({ + host: "127.0.0.1", + port: 54321, + user: "postgres", + password: "postgres", + database: "postgres", + runtimeParams: { statement_timeout: "15000" }, + connectTimeoutSeconds: 15, + }); + expect(call?.isLocal).toBe(true); + expect(call?.includedSchemas).toEqual(["public", "custom"]); + expect(call?.detectOneToOneRelationships).toBe(true); + expect(call?.conn.sslmode).toBeUndefined(); + expect(call?.conn.sslrootcertInline).toBeUndefined(); + expect(linkedProjectCache.cached).toBe(false); }); - }); - }); + }, + ); - it.live("resolves the linked fallback without a local project config", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-fallback-no-config-")); - const { layer, api } = setup({ + it.live("falls back to podman when the docker executable is missing for local generation", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-")); + writeConfig( workdir, - skipConfig: true, - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); + ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( + "\n", + ), + ); + const { layer, out, child, generator } = setup({ workdir, childDockerMissing: true }); return Effect.gen(function* () { - yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(api.requests[0]).toEqual({ - method: "generateTypescriptTypes", - input: { ref: VALID_REF, included_schemas: "public" }, - }); + expect(out.stdoutText).toContain("generated"); + expect(child.calls.map((call) => call.command)).toEqual(["docker", "podman"]); + expect(child.calls[1]?.args).toEqual(["container", "inspect", "supabase_db_demo"]); + expect(generator.calls).toHaveLength(1); }); }); - it.live("ignores positional language scanning when argv lacks the gen types context", () => { - const { layer, api } = setup({ - args: ["unrelated", "argv"], - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); + it.live("uses sanitized local docker ids and env-backed local db passwords", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-sanitized-")); + writeConfig( + workdir, + [ + 'project_id = "..demo project with spaces"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer, child, generator } = setup({ workdir }); return Effect.gen(function* () { - yield* genTypes(defaultFlags({ projectId: Option.some(VALID_REF) })).pipe( + yield* genTypes(defaultFlags({ local: true })).pipe( Effect.provide(layer), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord({ SUPABASE_DB_PASSWORD: "secret-password" }), + ), ); - expect(api.requests).toHaveLength(1); + expect(child.calls[0]?.args).toEqual([ + "container", + "inspect", + "supabase_db_demo_project_with_spaces", + ]); + expect(generator.calls[0]?.conn.password).toBe("secret-password"); + expect(generator.calls[0]?.conn.host).toBe("127.0.0.1"); }); }); - it.live("rejects a non-typescript language passed after a -- separator", () => { - const { layer } = setup({ args: ["gen", "types", "--", "go"] }); + it.live("forces v9 compat when rest-version reports v9 on a modern database", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-v9-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "major_version = 15", + "port = 54321", + ].join("\n"), + ); + writeTempFile(workdir, "rest-version", "v9.0.1\n"); + const { layer, generator } = setup({ workdir }); return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } - }); - }); - - it.live("treats a trailing -- with no operand as no positional language", () => { - const { layer, api } = setup({ - args: ["gen", "types", "--"], - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - return Effect.gen(function* () { - yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); - expect(api.requests).toHaveLength(1); + expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); }); }); - it.live("treats a positional after a valueless long flag as the language", () => { - const { layer } = setup({ args: ["gen", "types", "--local", "go"] }); + it.live("ignores rest-version v9 marker on databases older than 15", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-pg14-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "major_version = 14", + "port = 54321", + ].join("\n"), + ); + writeTempFile(workdir, "rest-version", "v9.0.1\n"); + const { layer, generator } = setup({ workdir }); return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.detectOneToOneRelationships).toBe(true); }); }); - it.live("treats a positional after a valueless short flag as the language", () => { - const { layer } = setup({ args: ["gen", "types", "-x", "go"] }); + it.live("prefers explicit --schema over config schemas for local generation", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-schema-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public", "custom"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer, generator } = setup({ workdir }); return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } + yield* genTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).pipe( + Effect.provide(layer), + ); + + expect(generator.calls[0]?.includedSchemas).toEqual(["auth", "storage"]); }); }); - it.live("prefers explicit --schema on the linked path", () => { - const { layer, api } = setup({ - projectId: Option.some(VALID_REF), - projectTypes: "ok", + it.live("allows --swift-access-control for local non-Swift generation", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-swift-flag-")); + writeConfig( + workdir, + ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( + "\n", + ), + ); + const { layer, generator } = setup({ + workdir, + args: ["gen", "types", "--local", "--lang", "python", "--swift-access-control", "public"], }); return Effect.gen(function* () { - yield* genTypes(defaultFlags({ linked: true, schema: ["auth"] })).pipe(Effect.provide(layer)); - expect(api.requests[0]).toEqual({ - method: "generateTypescriptTypes", - input: { ref: VALID_REF, included_schemas: "auth" }, - }); + yield* genTypes( + defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), + ).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.lang).toBe("python"); + expect(generator.calls[0]?.swiftAccessControl).toBe("public"); }); }); - it.live("prefers explicit --schema on the linked fallback path", () => { - const { layer, api } = setup({ - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); + it.live("falls back to the workdir basename when config has no project_id", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-noid-")); + writeConfig(workdir, ["[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join("\n")); + const { layer, child } = setup({ workdir }); return Effect.gen(function* () { - yield* genTypes(defaultFlags({ schema: ["auth"] })).pipe(Effect.provide(layer)); - expect(api.requests[0]).toEqual({ - method: "generateTypescriptTypes", - input: { ref: VALID_REF, included_schemas: "auth" }, - }); + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + const inspectId = child.calls[0]?.args[2] ?? ""; + expect(inspectId.startsWith("supabase_db_")).toBe(true); + expect(inspectId).not.toBe("supabase_db_demo"); }); }); @@ -3280,15 +2313,11 @@ describe("gen types", () => { "\n", ), ); - const child = mockDockerMissingChildProcessSpawner([ - { - exitCode: 1, - stderr: ['Error: inspecting object: no such container "supabase_db_demo"'], - }, - ]); - const { layer } = setup({ + const { layer, child } = setup({ workdir, - childLayer: child.layer, + childDockerMissing: true, + childExitCode: 1, + childStderr: ['Error: inspecting object: no such container "supabase_db_demo"'], }); return Effect.gen(function* () { @@ -3301,10 +2330,7 @@ describe("gen types", () => { if (Exit.isFailure(exit)) { expect(String(exit.cause)).toContain("supabase start is not running."); } - expect(child.spawned).toEqual([ - { command: "docker", args: ["container", "inspect", "supabase_db_demo"] }, - { command: "podman", args: ["container", "inspect", "supabase_db_demo"] }, - ]); + expect(child.calls.map((call) => call.command)).toEqual(["docker", "podman"]); }); }); @@ -3348,46 +2374,23 @@ describe("gen types", () => { it.live("generates locally with Go defaults when supabase/config.toml is missing", () => { const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-")); - const docker = captureDockerRun(); - const probes: Array<{ host: string; port: number }> = []; - const { layer, out, child } = setup({ - workdir, - skipConfig: true, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - dbConfigResolve: () => - Effect.succeed( - localResolvedConfig({ - host: "127.0.0.1", - port: 54322, - user: "postgres", - password: "postgres", - database: "postgres", - }), - ), - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: (host, port) => - Effect.sync(() => { - probes.push({ host, port }); - return false; - }), - }), - }); + const { layer, out, child, generator } = setup({ workdir, skipConfig: true }); return Effect.gen(function* () { yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); const projectId = basename(workdir); - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", localDbContainerId(projectId)], + expect(child.calls[0]?.args).toEqual(["container", "inspect", localDbContainerId(projectId)]); + expect(generator.calls[0]?.conn).toEqual({ + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + runtimeParams: { statement_timeout: "15000" }, + connectTimeoutSeconds: 15, }); - expect(child.spawned[2]?.args).toContain(localNetworkId(projectId)); - expect(probes).toEqual([{ host: "127.0.0.1", port: 54322 }]); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,graphql_public")).toBe( - true, - ); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "graphql_public"]); expect(out.stdoutText).toContain("generated"); }); }); @@ -3401,62 +2404,26 @@ describe("gen types", () => { [ "SUPABASE_PROJECT_ID=configless-env-project", "SUPABASE_DB_PORT=55432", - "SUPABASE_DB_PASSWORD=remote-password", "SUPABASE_API_SCHEMAS=private,graphql_public", "SUPABASE_SERVICES_HOSTNAME=host.docker.internal", "SUPABASE_INTERNAL_IMAGE_REGISTRY=mirror.example.com", "", ].join("\n"), ); - const docker = captureDockerRun(); - const probes: Array<{ host: string; port: number }> = []; - const { layer, out, child } = setup({ - workdir, - skipConfig: true, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - dbConfigResolve: () => - Effect.succeed( - localResolvedConfig({ - host: "host.docker.internal", - port: 55432, - user: "postgres", - password: "postgres", - database: "postgres", - }), - ), - sslProbeLayer: Layer.succeed(PgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: (host, port) => - Effect.sync(() => { - probes.push({ host, port }); - return false; - }), - }), - }); + const { layer, out, child, generator } = setup({ workdir, skipConfig: true }); return Effect.gen(function* () { yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", localDbContainerId("configless-env-project")], - }); - expect(child.spawned[2]?.args).toContain(localNetworkId("configless-env-project")); - expect(probes).toEqual([{ host: "host.docker.internal", port: 55432 }]); - expect( - docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private,graphql_public"), - ).toBe(true); - expect( - docker.env.has( - "PG_META_DB_URL=postgresql://postgres:postgres@db:5432/postgres?connect_timeout=10", - ), - ).toBe(true); - expect( - child.spawned[2]?.args.some((arg) => - arg.startsWith("mirror.example.com/supabase/postgres-meta:"), - ), - ).toBe(true); + expect(child.calls[0]?.args).toEqual([ + "container", + "inspect", + localDbContainerId("configless-env-project"), + ]); + expect(child.calls[0]?.env?.["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("mirror.example.com"); + expect(generator.calls[0]?.conn.host).toBe("host.docker.internal"); + expect(generator.calls[0]?.conn.port).toBe(55432); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private", "graphql_public"]); expect(out.stdoutText).toContain("generated"); }); }); @@ -3485,320 +2452,207 @@ describe("gen types", () => { }); }); - it.live("defaults schemas to public for a db-url run without a project config", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-dburl-no-config-")); - const { layer } = setup({ - workdir, - skipConfig: true, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - }), - ).pipe(Effect.provide(layer)), - ); + it.live("surfaces generation failures after local db inspection succeeds", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-run-error-")); + writeConfig( + workdir, + ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( + "\n", + ), + ); + const generator = mockGenTypesGenerator({ + generate: () => + Effect.fail( + new GenTypesGenerationError({ message: "failed to generate typescript types: boom" }), + ), + }); + const { layer, child } = setup({ workdir, generator }); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); - it.live("surfaces pg-meta container failures after local db inspection succeeds", () => { - return Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-run-error-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - const sequence = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { exitCode: 0 }, - { exitCode: 1, stderr: ["pg-meta failed"] }, - ]); - const { layer } = setup({ - workdir, - childLayer: sequence.layer, - }); - - const exit = await Effect.runPromise( - genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer), Effect.exit), - ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("failed to generate typescript types: boom"); + } + expect(child.calls).toHaveLength(1); + expect(generator.calls).toHaveLength(1); + }); + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("error running container: exit 1"); - } - expect(sequence.spawned).toHaveLength(3); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); - }); - - it.live("spawns pg-meta for db-url generation", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, out, child } = setup({ - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - lang: "swift", - schema: ["public"], - swiftAccessControl: "public", - postgrestV9Compat: true, - queryTimeout: "20s", - }), - ).pipe(Effect.provide(layer)), - ); + // --- Local generation: stack backend (no Docker inspection at all) ---------------------- - expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); - expect(child.spawned[1]?.args).toContain("--network"); - expect(child.spawned[1]?.args).toContain("host"); - expect(docker.env.has("PG_META_GENERATE_TYPES=swift")).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("resolves the stack local database directly, without inspecting any container", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-stack-local-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public", "custom"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer, out, child, dbConfig, generator } = setup({ + workdir, + dbConfigResolve: () => + Effect.succeed( + localResolvedConfig({ + host: "127.0.0.1", + port: 54321, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ), + }); - it.live("injects the CA bundle env var when the database speaks TLS", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"))), + ); - expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(true); - }, "S"), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(out.stderrText).toContain("Connecting to 127.0.0.1 54321"); + expect(child.calls).toHaveLength(0); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("local"); + expect(generator.calls).toHaveLength(1); + const call = generator.calls[0]; + expect(call?.conn).toMatchObject({ + host: "127.0.0.1", + port: 54321, + user: "postgres", + password: "postgres", + }); + expect(call?.isLocal).toBe(true); + expect(call?.includedSchemas).toEqual(["public", "custom"]); + // The stack backend never pins TLS for a local target either. + expect(call?.conn.sslmode).toBeUndefined(); + expect(call?.conn.sslrootcertInline).toBeUndefined(); + }); + }); - it.live("passes the CA bundle env var in --debug mode when TLS is supported", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - childStdout: ["generated"], - debug: true, - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + // --- db-url generation --------------------------------------------------------------- - expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(true); - }, "S"), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + it.live( + "--db-url --schema succeeds on an explicit --workdir with no project of its own, since an explicit schema never needs the config load", + () => { + const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); + writeConfig( + root, + ['project_id = "demo"', "", "[api]", 'schemas = ["ancestor_only"]'].join("\n"), + ); + const sub = join(root, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, dbConfig, generator } = setup({ + workdir: sub, + skipConfig: true, + explicitWorkdir: true, + }); - it.live("warns on stderr when SUPABASE_CA_SKIP_VERIFY is enabled", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const { layer, out } = setup({ childStdout: ["generated"] }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe( - Effect.provide(layer), - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord({ SUPABASE_CA_SKIP_VERIFY: "true" }), - ), - ), - ); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + schema: ["public"], + }), + ).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain( - "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)", - ); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("db-url"); + expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); + }); + }, ); - it.live("honors the --network-id override for the db-url connection", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, child } = setup({ - childStdout: ["generated"], - networkId: Option.some("custom-network"), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + it.live( + "resolves db-url generation through the DbConfigResolver, defaulting schemas from config", + () => { + const { layer, dbConfig, generator } = setup(); - expect(child.spawned[1]?.args).toContain("custom-network"); - expect(child.spawned[1]?.args).not.toContain("host"); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + }), + ).pipe(Effect.provide(layer)); + + expect(dbConfig.resolves[0]).toEqual({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + connType: "db-url", + dnsResolver: "native", + }); + expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); + expect(generator.calls[0]?.isLocal).toBe(false); + expect(generator.calls[0]?.conn.runtimeParams?.["statement_timeout"]).toBe("15000"); + expect(generator.calls[0]?.conn.connectTimeoutSeconds).toBe(15); + }); + }, ); - it.live("defaults bare db-url connections to the postgres database", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}`), - lang: "swift", - schema: ["public"], - swiftAccessControl: "public", - postgrestV9Compat: true, - queryTimeout: "20s", - }), - ).pipe(Effect.provide(layer)), - ); + it.live( + "forwards --lang/--swift-access-control/--postgrest-v9-compat/--query-timeout for db-url generation", + () => { + const { layer, generator } = setup(); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, - ), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + lang: "swift", + schema: ["public"], + swiftAccessControl: "public", + postgrestV9Compat: true, + queryTimeout: "20s", + }), + ).pipe(Effect.provide(layer)); + + const call = generator.calls[0]; + expect(call?.lang).toBe("swift"); + expect(call?.swiftAccessControl).toBe("public"); + expect(call?.detectOneToOneRelationships).toBe(false); + expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); + expect(call?.conn.connectTimeoutSeconds).toBe(20); + }); + }, ); - it.live("accepts legacy positional typescript without changing behavior", () => { - const { layer } = setup({ - args: ["gen", "types", "typescript"], - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); + it.live("allows --postgrest-v9-compat together with --db-url", () => { + const { layer, generator } = setup(); return Effect.gen(function* () { - yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + postgrestV9Compat: true, + }), + ).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); }); }); - it.live("rejects legacy positional non-typescript without an explicit lang flag", () => { - const { layer } = setup({ - args: ["gen", "types", "go"], + it.live("allows legacy positional non-typescript when --lang is explicitly set", () => { + const { layer, generator } = setup({ + args: ["gen", "types", "go", "--lang", "go"], }); return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + lang: "go", + schema: ["public"], + }), + ).pipe(Effect.provide(layer)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } + expect(generator.calls[0]?.lang).toBe("go"); }); }); - - it.live( - "rejects legacy positional non-typescript after consuming short flags with values", - () => { - const { layer } = setup({ - args: ["gen", "types", "-o", "json", "go"], - goOutput: Option.some("json"), - }); - - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } - }); - }, - ); - - it.live("allows legacy positional non-typescript when --lang is explicitly set", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - args: ["gen", "types", "go", "--lang", "go"], - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - genTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - lang: "go", - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); - - expect(docker.env.has("PG_META_GENERATE_TYPES=go")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); }); diff --git a/apps/cli/src/commands/gen/types/types.layers.ts b/apps/cli/src/commands/gen/types/types.layers.ts index 55e75f2191..5a8bffb81a 100644 --- a/apps/cli/src/commands/gen/types/types.layers.ts +++ b/apps/cli/src/commands/gen/types/types.layers.ts @@ -10,9 +10,8 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { dbConfigLayer } from "../../../command-internal/db-config.layer.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { dbConnectionLayer } from "../../../command-internal/db-connection.layer.ts"; +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import { debugLoggerLayer } from "../../../command-internal/debug-logger.layer.ts"; -import { pgDeltaSslProbeLayer } from "../../../command-internal/pgdelta-ssl-probe.layer.ts"; -import { PgDeltaSslProbe } from "../../../command-internal/pgdelta-ssl-probe.service.ts"; import { IdentityStitch, identityStitchLayer } from "../../../command-internal/identity-stitch.ts"; import { httpClientLayer } from "../../../auth/http-debug.layer.ts"; import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache.layer.ts"; @@ -21,6 +20,8 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; +import { genTypesGeneratorLayer } from "./types.generator.layer.ts"; +import { GenTypesGenerator } from "./types.generator.service.ts"; /** * Avoids `managementApiRuntimeLayer`, which eagerly builds the platform API client and @@ -48,10 +49,12 @@ export const genTypesRuntimeLayer = (() => { Layer.provide(debugLoggerLayer), Layer.provide(identityStitchLayer), ); + const generator = genTypesGeneratorLayer.pipe(Layer.provide(dbConnectionLayer)); const built = Layer.mergeAll( dbConfig, dbConnectionLayer, + generator, cliSettings, platformApiFactory, projectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliSettings)), @@ -61,7 +64,6 @@ export const genTypesRuntimeLayer = (() => { Layer.provide(httpClient), Layer.provide(identityStitchLayer), ), - pgDeltaSslProbeLayer, telemetryStateLayer, // Exposed at top level so `withCommandTelemetry` can read `stitchedDistinctId()` and // attribute the cli_command_executed event to the gotrue id. @@ -80,7 +82,8 @@ type GenTypesServices = | CommandSettings | ProjectRefResolver | DbConfigResolver - | PgDeltaSslProbe + | DbConnection + | GenTypesGenerator | LinkedProjectCache | TelemetryState | IdentityStitch diff --git a/apps/cli/src/commands/gen/types/types.oxfmt.ts b/apps/cli/src/commands/gen/types/types.oxfmt.ts new file mode 100644 index 0000000000..c98d0f34f9 --- /dev/null +++ b/apps/cli/src/commands/gen/types/types.oxfmt.ts @@ -0,0 +1,141 @@ +/** + * Statically-dispatched oxfmt native binding used to format generated TypeScript. + * + * `@supabase/postgrest-typegen` formats through the `oxfmt` JS package, whose ESM dist + * resolves its platform binding via `createRequire(import.meta.url)` — a dynamic path + * `bun build --compile` cannot follow, leaving the compiled binary unable to find the `.node` + * addon. One static `require` per shipped target makes Bun embed the right binding, which is + * then injected through the generator's `format` option. + */ + +import { createRequire } from "node:module"; + +declare const SUPABASE_LIBC: string | undefined; + +/** + * Callback the binding invokes for embedded languages. Generated type declarations contain no + * template literals, so it can never fire. + */ +type OxfmtEmbedCallback = (options: unknown, code: unknown) => never; + +interface OxfmtBinding { + readonly format: ( + fileName: string, + sourceText: string, + options: Readonly>, + formatFileCallback: OxfmtEmbedCallback, + formatEmbeddedCodeCallback: OxfmtEmbedCallback, + formatEmbeddedDocCallback: OxfmtEmbedCallback, + ) => Promise<{ + readonly code: string; + readonly errors: ReadonlyArray<{ readonly message: string }>; + }>; +} + +const sourceRequire = createRequire(import.meta.url); + +/** + * Source-run ESM has no `require` binding and throws `ReferenceError`; compiled Bun injects one + * that loads embedded `.node` addons. `createRequire` is the source-run fallback only — in the + * compiled binary it resolves from `/$bunfs/root` and misses those addons. + */ +function loadOxfmtBinding(loadCompiled: () => OxfmtBinding, specifier: string): OxfmtBinding { + try { + return loadCompiled(); + } catch (error) { + if (error instanceof ReferenceError) { + return sourceRequire(specifier); + } + throw error; + } +} + +function requireOxfmtBinding(): OxfmtBinding { + if (process.platform === "darwin") { + if (process.arch === "arm64") { + return loadOxfmtBinding( + () => require("@oxfmt/binding-darwin-arm64"), + "@oxfmt/binding-darwin-arm64", + ); + } + if (process.arch === "x64") { + return loadOxfmtBinding( + () => require("@oxfmt/binding-darwin-x64"), + "@oxfmt/binding-darwin-x64", + ); + } + } + + if (process.platform === "linux") { + if (process.arch === "arm64") { + if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") { + return loadOxfmtBinding( + () => require("@oxfmt/binding-linux-arm64-musl"), + "@oxfmt/binding-linux-arm64-musl", + ); + } + return loadOxfmtBinding( + () => require("@oxfmt/binding-linux-arm64-gnu"), + "@oxfmt/binding-linux-arm64-gnu", + ); + } + if (process.arch === "x64") { + if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") { + return loadOxfmtBinding( + () => require("@oxfmt/binding-linux-x64-musl"), + "@oxfmt/binding-linux-x64-musl", + ); + } + return loadOxfmtBinding( + () => require("@oxfmt/binding-linux-x64-gnu"), + "@oxfmt/binding-linux-x64-gnu", + ); + } + } + + if (process.platform === "win32") { + if (process.arch === "arm64") { + return loadOxfmtBinding( + () => require("@oxfmt/binding-win32-arm64-msvc"), + "@oxfmt/binding-win32-arm64-msvc", + ); + } + if (process.arch === "x64") { + return loadOxfmtBinding( + () => require("@oxfmt/binding-win32-x64-msvc"), + "@oxfmt/binding-win32-x64-msvc", + ); + } + } + + throw new Error(`Unsupported oxfmt platform: ${process.platform}-${process.arch}`); +} + +const rejectEmbedded: OxfmtEmbedCallback = () => { + throw new Error("embedded-language formatting is not available for generated types"); +}; + +/** + * Drop-in for `GenerateTypescriptOptions.format`, byte-equivalent to the typegen package's own + * oxfmt default. The binding version in `package.json` must track the `oxfmt` version pinned by + * `@supabase/postgrest-typegen`, and these options must mirror its `defaultFormat`. + */ +export async function oxfmtTypegenFormat(code: string): Promise { + const binding = requireOxfmtBinding(); + const { code: formatted, errors } = await binding.format( + "output.ts", + code, + { semi: false, printWidth: 80 }, + rejectEmbedded, + rejectEmbedded, + rejectEmbedded, + ); + if (errors.length > 0) { + throw new Error( + `oxfmt failed to format generated TypeScript output: ${errors + .map((error) => error.message) + .join("; ")}`, + ); + } + return formatted; +} diff --git a/apps/cli/src/commands/gen/types/types.shared.ts b/apps/cli/src/commands/gen/types/types.shared.ts index 78d73c99a0..1cffdfcb3c 100644 --- a/apps/cli/src/commands/gen/types/types.shared.ts +++ b/apps/cli/src/commands/gen/types/types.shared.ts @@ -1,8 +1,5 @@ import { Config, Effect, Option } from "effect"; -import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; -import { slimImageForCurrentPin } from "../../../shared/services/slim-images.ts"; -import { getRegistryImageUrl } from "../../../command-internal/docker-registry.ts"; -import { InvalidGenTypesDatabaseUrlError, InvalidGenTypesDurationError } from "./types.errors.ts"; +import { InvalidGenTypesDurationError } from "./types.errors.ts"; import caProd2021 from "./templates/prod-ca-2021.ts"; import caProd2025 from "./templates/prod-ca-2025.ts"; import caStaging2021 from "./templates/staging-ca-2021.ts"; @@ -11,8 +8,6 @@ import caStaging2021 from "./templates/staging-ca-2021.ts"; // can derive the same `supabase_db_` name when checking the local stack. export { localDbContainerId, localNetworkId } from "../../../command-internal/docker-ids.ts"; -const DEFAULT_CONNECT_TIMEOUT_SECONDS = 10; - const DURATION_UNITS_TO_MILLIS = { ns: 1 / 1_000_000, us: 1 / 1_000, @@ -29,13 +24,6 @@ const DURATION_PART_PATTERN = new RegExp( "g", ); -export interface GenTypesDbTarget { - readonly url: string; - readonly host: string; - readonly port: number; - readonly networkMode: "host" | (string & {}); -} - export function defaultSchemas(extraSchemas: ReadonlyArray = []) { return [...new Set(["public", ...extraSchemas])]; } @@ -96,68 +84,6 @@ export const localDbPassword = Effect.fnUntraced(function* () { return Option.getOrElse(value, () => "postgres"); }); -export function parseDatabaseUrl( - url: string, -): Effect.Effect { - return Effect.try({ - try: () => { - const parsed = new URL(url); - if (parsed.protocol !== "postgresql:" && parsed.protocol !== "postgres:") { - throw new Error(`unsupported scheme ${parsed.protocol}`); - } - if (parsed.pathname.length === 0 || parsed.pathname === "/") { - parsed.pathname = "/postgres"; - } - return { - url: parsed.toString(), - host: parsed.hostname, - port: parsed.port.length > 0 ? Number.parseInt(parsed.port, 10) : 5432, - networkMode: "host" as const, - } satisfies GenTypesDbTarget; - }, - catch: (cause) => - new InvalidGenTypesDatabaseUrlError({ - message: `failed to parse connection string: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); -} - -export function buildPostgresUrl(input: { - readonly host: string; - readonly port: number; - readonly user: string; - readonly password: string; - readonly database: string; -}) { - const host = - input.host.includes(":") && !input.host.startsWith("[") ? `[${input.host}]` : input.host; - return ( - `postgresql://${encodeURIComponent(input.user)}:${encodeURIComponent(input.password)}` + - `@${host}:${input.port}/${encodeURIComponent(input.database)}` + - `?connect_timeout=${DEFAULT_CONNECT_TIMEOUT_SECONDS}` - ); -} - -export function resolvePgmetaImage( - versionOverride?: string, - projectEnvValues?: Readonly>, -) { - const raw = dockerfileServiceImageRaw("pgmeta"); - const trimmed = versionOverride?.trim() ?? ""; - const pin = trimmed.length > 0 ? `v${trimmed.replace(/^v/i, "")}` : undefined; - const projectSlimValue = Option.fromNullishOr(projectEnvValues?.["SUPABASE_USE_SLIM_IMAGES"]); - const useSlimImages = Option.isSome(projectSlimValue) - ? Effect.succeed(projectSlimValue.value === "true" || projectSlimValue.value === "1") - : Config.string("SUPABASE_USE_SLIM_IMAGES").pipe( - Config.withDefault(""), - Effect.map((value) => value === "true" || value === "1"), - ); - return useSlimImages.pipe( - Effect.map((enabled) => slimImageForCurrentPin("pgmeta", raw, pin, enabled)), - Effect.flatMap((image) => getRegistryImageUrl(image, projectEnvValues)), - ); -} - export function rootCaBundle() { return `${caStaging2021}${caProd2021}${caProd2025}`; } diff --git a/apps/cli/src/commands/gen/types/types.unit.test.ts b/apps/cli/src/commands/gen/types/types.unit.test.ts index c289cb01dc..ea16065e64 100644 --- a/apps/cli/src/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.unit.test.ts @@ -2,37 +2,17 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { ConfigProvider, Effect, Exit, Layer } from "effect"; import { runtimeInfoLayer } from "../../../shared/runtime/runtime-info.layer.ts"; -import { dockerfileServiceImageRaw } from "../../../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../../../shared/services/slim-images.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; import { - buildPostgresUrl, defaultSchemas, rootCaBundle, localDbContainerId, localDbPassword, localNetworkId, - parseDatabaseUrl, parseQueryTimeoutSeconds, - resolvePgmetaImage, } from "./types.shared.ts"; -const currentPgmeta = dockerfileServiceImageRaw("pgmeta"); -const currentPgmetaTag = currentPgmeta.split(":")[1] ?? ""; -const resolvePgmeta = ( - version?: string, - env?: Readonly>, - ambient: Readonly> = { ...process.env }, -) => - Effect.runSync( - resolvePgmetaImage(version, env).pipe( - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord(ambient, { preserveEmptyStrings: true }), - ), - ), - ); const resolvePassword = () => Effect.runSync( localDbPassword().pipe( @@ -114,160 +94,6 @@ describe("parseQueryTimeoutSeconds", () => { ); }); -describe("parseDatabaseUrl", () => { - it.effect("parses a full postgresql url", () => - Effect.gen(function* () { - const result = yield* parseDatabaseUrl("postgresql://user:pw@example.com:6543/mydb"); - expect(result.host).toBe("example.com"); - expect(result.port).toBe(6543); - expect(result.networkMode).toBe("host"); - expect(result.url).toContain("/mydb"); - }), - ); - - it.effect("accepts the postgres:// scheme and defaults the database", () => - Effect.gen(function* () { - const result = yield* parseDatabaseUrl("postgres://user:pw@example.com/"); - expect(result.url).toContain("/postgres"); - }), - ); - - it.effect("defaults the port to 5432 when omitted", () => - Effect.gen(function* () { - const result = yield* parseDatabaseUrl("postgresql://user:pw@example.com/db"); - expect(result.port).toBe(5432); - }), - ); - - it.effect("rejects an unsupported scheme", () => - Effect.gen(function* () { - const exit = yield* parseDatabaseUrl("mysql://user:pw@example.com/db").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); - - it.effect("rejects a malformed connection string", () => - Effect.gen(function* () { - const exit = yield* parseDatabaseUrl("not a url").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); -}); - -describe("resolvePgmetaImage", () => { - it("uses the default pgmeta version when no override is given", () => { - const image = withEnv("SUPABASE_USE_SLIM_IMAGES", undefined, () => - withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => resolvePgmeta()), - ); - expect(image).toContain("postgres-meta"); - }); - - it("strips a leading v from a version override", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => - resolvePgmeta("v1.2.3"), - ); - expect(image).toBe("supabase/postgres-meta:v1.2.3"); - }); - - it("falls back to the default when the override is blank", () => { - const withOverride = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => - resolvePgmeta(" "), - ); - const withoutOverride = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => - resolvePgmeta(), - ); - expect(withOverride).toBe(withoutOverride); - }); - - it("uses the supabase registry for any non docker.io registry", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => - resolvePgmeta("1.2.3"), - ); - expect(image).not.toBe("supabase/postgres-meta:v1.2.3"); - expect(image).toContain("postgres-meta:v1.2.3"); - }); - - it("defaults to the ECR mirror when no registry override is set", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => - resolvePgmeta("1.2.3"), - ); - expect(image).toBe("public.ecr.aws/supabase/postgres-meta:v1.2.3"); - }); - - it("honors SUPABASE_INTERNAL_IMAGE_REGISTRY for a non docker.io registry (e.g. ghcr.io)", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "ghcr.io", () => - resolvePgmeta("1.2.3"), - ); - expect(image).toBe("ghcr.io/supabase/postgres-meta:v1.2.3"); - }); - - it("rewrites to an arbitrary configured mirror registry", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "my.registry.example", () => - resolvePgmeta("1.2.3"), - ); - expect(image).toBe("my.registry.example/supabase/postgres-meta:v1.2.3"); - }); - - it("slim-translates the current pin and skips registry rewrite", () => { - const image = withEnv("SUPABASE_USE_SLIM_IMAGES", "1", () => - withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => resolvePgmeta(currentPgmetaTag)), - ); - expect(image).toBe(toSlimImage("pgmeta", currentPgmeta)); - }); - - it("uses a project-only slim flag without mutating ambient configuration", () => { - const image = resolvePgmeta( - currentPgmetaTag, - { - SUPABASE_USE_SLIM_IMAGES: "1", - SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", - }, - {}, - ); - expect(image).toBe(toSlimImage("pgmeta", currentPgmeta)); - }); - - it.each(["", "false"])("treats a project %j slim flag as disabled", (value) => { - const image = resolvePgmeta( - currentPgmetaTag, - { - SUPABASE_USE_SLIM_IMAGES: value, - SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", - }, - { SUPABASE_USE_SLIM_IMAGES: "1" }, - ); - expect(image).toBe(currentPgmeta); - }); - - it("uses the ambient slim flag when the project has no override", () => { - const image = resolvePgmeta( - currentPgmetaTag, - { SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io" }, - { SUPABASE_USE_SLIM_IMAGES: "1" }, - ); - expect(image).toBe(toSlimImage("pgmeta", currentPgmeta)); - }); - - it("keeps a historical project pin on the source image under the project slim flag", () => { - const image = resolvePgmeta( - "1.2.3", - { - SUPABASE_USE_SLIM_IMAGES: "1", - SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", - }, - {}, - ); - expect(image).toBe("supabase/postgres-meta:v1.2.3"); - }); - - it("keeps a historical pg-meta pin on docker.io under the slim flag", () => { - const image = withEnv("SUPABASE_USE_SLIM_IMAGES", "1", () => - withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => resolvePgmeta("1.2.3")), - ); - expect(image).toBe("supabase/postgres-meta:v1.2.3"); - }); -}); - describe("schema and id helpers", () => { it("normalizes comma separated and repeated schema flags", () => { // pflag's StringSlice parses via encoding/csv with no trimming; an empty value yields no field. @@ -310,17 +136,6 @@ describe("schema and id helpers", () => { ), ); - it("brackets ipv6 hosts in the generated postgres url", () => { - const url = buildPostgresUrl({ - host: "::1", - port: 5432, - user: "postgres", - password: "pw", - database: "postgres", - }); - expect(url).toContain("@[::1]:5432/"); - }); - it("bundles the staging and production CA certificates", () => { expect(rootCaBundle().length).toBeGreaterThan(0); }); diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index eaf0577e86..8cecb57445 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -281,8 +281,10 @@ GenSigningKeyDecodeError GenSigningKeyGenerateError GenSigningKeyReadError GenSigningKeyWriteError +GenTypesGenerationError GenTypesMissingProjectConfigError GenTypesNetworkError +GenTypesNetworkIdUnsupportedError GenTypesParseConfigError GenTypesUnexpectedStatusError GenTypesWorkdirError @@ -303,7 +305,6 @@ InvalidComputeSourceError InvalidFunctionDeploySlugError InvalidFunctionDownloadResponseError InvalidFunctionSlugError -InvalidGenTypesDatabaseUrlError InvalidGenTypesDurationError InvalidLocalServiceVersionsStateError InvalidOutputFormatError diff --git a/apps/cli/tsconfig.types.json b/apps/cli/tsconfig.types.json new file mode 100644 index 0000000000..fa1dfeebf8 --- /dev/null +++ b/apps/cli/tsconfig.types.json @@ -0,0 +1,16 @@ +{ + // Type-check-only overlay. `@supabase/postgrest-typegen`'s `bun` exports condition points at + // its unbuilt `src/*.ts`, which does not satisfy this workspace's stricter compiler options; + // its published `dist/*.d.ts` describes the same API and does. The pin cannot live in + // `tsconfig.json` because Bun honours `paths` at runtime too, and would then load a + // declaration file instead of the implementation. `types.generator.integration.test.ts` + // exercises the Bun-resolved runtime API so the two views cannot drift unnoticed. + "extends": "./tsconfig.json", + "compilerOptions": { + // `paths` replaces the base map wholesale, so the inherited pin is repeated here. + "paths": { + "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"], + "@supabase/postgrest-typegen": ["./node_modules/@supabase/postgrest-typegen/dist/index.d.ts"] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 720aa2a3fb..e036f77f7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,30 @@ importers: '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 + '@oxfmt/binding-darwin-arm64': + specifier: 0.66.0 + version: 0.66.0 + '@oxfmt/binding-darwin-x64': + specifier: 0.66.0 + version: 0.66.0 + '@oxfmt/binding-linux-arm64-gnu': + specifier: 0.66.0 + version: 0.66.0 + '@oxfmt/binding-linux-arm64-musl': + specifier: 0.66.0 + version: 0.66.0 + '@oxfmt/binding-linux-x64-gnu': + specifier: 0.66.0 + version: 0.66.0 + '@oxfmt/binding-linux-x64-musl': + specifier: 0.66.0 + version: 0.66.0 + '@oxfmt/binding-win32-arm64-msvc': + specifier: 0.66.0 + version: 0.66.0 + '@oxfmt/binding-win32-x64-msvc': + specifier: 0.66.0 + version: 0.66.0 '@supabase/api': specifier: workspace:* version: link:../../packages/api @@ -334,6 +358,9 @@ importers: '@supabase/pg-topo': specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6(supports-color@7.2.0) + '@supabase/postgrest-typegen': + specifier: 0.2.2 + version: 0.2.2 '@supabase/stack': specifier: workspace:* version: link:../../packages/stack @@ -713,6 +740,12 @@ packages: zod: optional: true + '@ark/schema@0.56.2': + resolution: {integrity: sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==} + + '@ark/util@0.56.2': + resolution: {integrity: sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1837,42 +1870,84 @@ packages: cpu: [arm] os: [android] + '@oxfmt/binding-android-arm-eabi@0.66.0': + resolution: {integrity: sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxfmt/binding-android-arm64@0.65.0': resolution: {integrity: sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxfmt/binding-android-arm64@0.66.0': + resolution: {integrity: sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxfmt/binding-darwin-arm64@0.65.0': resolution: {integrity: sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxfmt/binding-darwin-arm64@0.66.0': + resolution: {integrity: sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxfmt/binding-darwin-x64@0.65.0': resolution: {integrity: sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxfmt/binding-darwin-x64@0.66.0': + resolution: {integrity: sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxfmt/binding-freebsd-x64@0.65.0': resolution: {integrity: sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxfmt/binding-freebsd-x64@0.66.0': + resolution: {integrity: sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': resolution: {integrity: sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': + resolution: {integrity: sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxfmt/binding-linux-arm-musleabihf@0.65.0': resolution: {integrity: sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxfmt/binding-linux-arm-musleabihf@0.66.0': + resolution: {integrity: sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxfmt/binding-linux-arm64-gnu@0.65.0': resolution: {integrity: sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1880,6 +1955,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-arm64-gnu@0.66.0': + resolution: {integrity: sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-arm64-musl@0.65.0': resolution: {integrity: sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1887,6 +1969,13 @@ packages: os: [linux] libc: [musl] + '@oxfmt/binding-linux-arm64-musl@0.66.0': + resolution: {integrity: sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxfmt/binding-linux-ppc64-gnu@0.65.0': resolution: {integrity: sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1894,6 +1983,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-ppc64-gnu@0.66.0': + resolution: {integrity: sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-riscv64-gnu@0.65.0': resolution: {integrity: sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1901,6 +1997,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-riscv64-gnu@0.66.0': + resolution: {integrity: sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-riscv64-musl@0.65.0': resolution: {integrity: sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1908,6 +2011,13 @@ packages: os: [linux] libc: [musl] + '@oxfmt/binding-linux-riscv64-musl@0.66.0': + resolution: {integrity: sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxfmt/binding-linux-s390x-gnu@0.65.0': resolution: {integrity: sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1915,6 +2025,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-s390x-gnu@0.66.0': + resolution: {integrity: sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-x64-gnu@0.65.0': resolution: {integrity: sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1922,6 +2039,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-x64-gnu@0.66.0': + resolution: {integrity: sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-x64-musl@0.65.0': resolution: {integrity: sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1929,30 +2053,61 @@ packages: os: [linux] libc: [musl] + '@oxfmt/binding-linux-x64-musl@0.66.0': + resolution: {integrity: sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxfmt/binding-openharmony-arm64@0.65.0': resolution: {integrity: sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxfmt/binding-openharmony-arm64@0.66.0': + resolution: {integrity: sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxfmt/binding-win32-arm64-msvc@0.65.0': resolution: {integrity: sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxfmt/binding-win32-arm64-msvc@0.66.0': + resolution: {integrity: sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxfmt/binding-win32-ia32-msvc@0.65.0': resolution: {integrity: sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxfmt/binding-win32-ia32-msvc@0.66.0': + resolution: {integrity: sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxfmt/binding-win32-x64-msvc@0.65.0': resolution: {integrity: sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxfmt/binding-win32-x64-msvc@0.66.0': + resolution: {integrity: sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint-tsgolint/darwin-arm64@7.0.2001': resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} cpu: [arm64] @@ -2776,6 +2931,10 @@ packages: resolution: {integrity: sha512-uaubtPSeg2TR4wrtfQoQWgkTAe+a0qWX2KhmwvTfNl5mGN9+U7owiJt6abk3o/V6O899PSRD1yzxs5RlF4xTug==} engines: {node: '>=22.0.0'} + '@supabase/postgrest-typegen@0.2.2': + resolution: {integrity: sha512-TKh+GgakrkMlLahWtvwa6WWzbRzlK4tBExNfgZbnB0V5yMl51RIOvrgITQOacoJR3S/ZZCNbpvkRgHrhxSWsBg==} + engines: {node: '>=20.0.0'} + '@supabase/realtime-js@2.112.4': resolution: {integrity: sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw==} engines: {node: '>=22.0.0'} @@ -3290,6 +3449,12 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + arkregex@0.0.8: + resolution: {integrity: sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==} + + arktype@2.2.3: + resolution: {integrity: sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg==} + array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -5348,6 +5513,19 @@ packages: vite-plus: optional: true + oxfmt@0.66.0: + resolution: {integrity: sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + oxlint-tsgolint@7.0.2001: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true @@ -6733,6 +6911,12 @@ snapshots: optionalDependencies: zod: 4.5.4 + '@ark/schema@0.56.2': + dependencies: + '@ark/util': 0.56.2 + + '@ark/util@0.56.2': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -7666,60 +7850,109 @@ snapshots: '@oxfmt/binding-android-arm-eabi@0.65.0': optional: true + '@oxfmt/binding-android-arm-eabi@0.66.0': + optional: true + '@oxfmt/binding-android-arm64@0.65.0': optional: true + '@oxfmt/binding-android-arm64@0.66.0': + optional: true + '@oxfmt/binding-darwin-arm64@0.65.0': optional: true + '@oxfmt/binding-darwin-arm64@0.66.0': {} + '@oxfmt/binding-darwin-x64@0.65.0': optional: true + '@oxfmt/binding-darwin-x64@0.66.0': {} + '@oxfmt/binding-freebsd-x64@0.65.0': optional: true + '@oxfmt/binding-freebsd-x64@0.66.0': + optional: true + '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': optional: true + '@oxfmt/binding-linux-arm-gnueabihf@0.66.0': + optional: true + '@oxfmt/binding-linux-arm-musleabihf@0.65.0': optional: true + '@oxfmt/binding-linux-arm-musleabihf@0.66.0': + optional: true + '@oxfmt/binding-linux-arm64-gnu@0.65.0': optional: true + '@oxfmt/binding-linux-arm64-gnu@0.66.0': {} + '@oxfmt/binding-linux-arm64-musl@0.65.0': optional: true + '@oxfmt/binding-linux-arm64-musl@0.66.0': {} + '@oxfmt/binding-linux-ppc64-gnu@0.65.0': optional: true + '@oxfmt/binding-linux-ppc64-gnu@0.66.0': + optional: true + '@oxfmt/binding-linux-riscv64-gnu@0.65.0': optional: true + '@oxfmt/binding-linux-riscv64-gnu@0.66.0': + optional: true + '@oxfmt/binding-linux-riscv64-musl@0.65.0': optional: true + '@oxfmt/binding-linux-riscv64-musl@0.66.0': + optional: true + '@oxfmt/binding-linux-s390x-gnu@0.65.0': optional: true + '@oxfmt/binding-linux-s390x-gnu@0.66.0': + optional: true + '@oxfmt/binding-linux-x64-gnu@0.65.0': optional: true + '@oxfmt/binding-linux-x64-gnu@0.66.0': {} + '@oxfmt/binding-linux-x64-musl@0.65.0': optional: true + '@oxfmt/binding-linux-x64-musl@0.66.0': {} + '@oxfmt/binding-openharmony-arm64@0.65.0': optional: true + '@oxfmt/binding-openharmony-arm64@0.66.0': + optional: true + '@oxfmt/binding-win32-arm64-msvc@0.65.0': optional: true + '@oxfmt/binding-win32-arm64-msvc@0.66.0': {} + '@oxfmt/binding-win32-ia32-msvc@0.65.0': optional: true + '@oxfmt/binding-win32-ia32-msvc@0.66.0': + optional: true + '@oxfmt/binding-win32-x64-msvc@0.65.0': optional: true + '@oxfmt/binding-win32-x64-msvc@0.66.0': {} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': dependencies: '@effect/tsgo': 0.37.0 @@ -8461,6 +8694,14 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/postgrest-typegen@0.2.2': + dependencies: + arktype: 2.2.3 + oxfmt: 0.66.0 + transitivePeerDependencies: + - svelte + - vite-plus + '@supabase/realtime-js@2.112.4': dependencies: '@supabase/phoenix': 0.4.5 @@ -8951,6 +9192,16 @@ snapshots: dependencies: tslib: 2.8.1 + arkregex@0.0.8: + dependencies: + '@ark/util': 0.56.2 + + arktype@2.2.3: + dependencies: + '@ark/schema': 0.56.2 + '@ark/util': 0.56.2 + arkregex: 0.0.8 + array-flatten@1.1.1: {} array-ify@1.0.0: {} @@ -11308,6 +11559,30 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.65.0 '@oxfmt/binding-win32-x64-msvc': 0.65.0 + oxfmt@0.66.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.66.0 + '@oxfmt/binding-android-arm64': 0.66.0 + '@oxfmt/binding-darwin-arm64': 0.66.0 + '@oxfmt/binding-darwin-x64': 0.66.0 + '@oxfmt/binding-freebsd-x64': 0.66.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.66.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.66.0 + '@oxfmt/binding-linux-arm64-gnu': 0.66.0 + '@oxfmt/binding-linux-arm64-musl': 0.66.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.66.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.66.0 + '@oxfmt/binding-linux-riscv64-musl': 0.66.0 + '@oxfmt/binding-linux-s390x-gnu': 0.66.0 + '@oxfmt/binding-linux-x64-gnu': 0.66.0 + '@oxfmt/binding-linux-x64-musl': 0.66.0 + '@oxfmt/binding-openharmony-arm64': 0.66.0 + '@oxfmt/binding-win32-arm64-msvc': 0.66.0 + '@oxfmt/binding-win32-ia32-msvc': 0.66.0 + '@oxfmt/binding-win32-x64-msvc': 0.66.0 + oxlint-tsgolint@7.0.2001: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 7.0.2001 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dbbf1a1a8d..ea68f580cf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -107,6 +107,7 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-rc.112" - "@supabase/pg-delta@1.0.0-alpha.52" - "@supabase/pg-topo@1.0.0-alpha.6" + - "@supabase/postgrest-typegen@0.2.2" - "@types/bun@1.4.0" - "bun-types@1.4.0" - "effect@4.0.0-rc.112" From 5b15cdd91f7cd7e25ac439617f9ec3d639dafa6e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 16 Sep 2026 18:49:27 +0100 Subject: [PATCH 2/4] fix(cli): address gen types review findings (CLI-2366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sub-second `--query-timeout` was unusable: the duration parser rounded to whole seconds, so `400ms` became `0`, which disabled `statement_timeout` server-side and collapsed the connect timeout to `Duration.seconds(0)` — the connection could never be established. The rounding only existed to satisfy pg-meta's integer-seconds environment contract, so the parser now returns milliseconds and the connection derives an exact `statement_timeout` and a connect timeout of at least one second. An explicit `0s` still disables the bound and leaves the driver's own connect default in place. Interrupting generation now aborts the in-flight introspection query, by forwarding `Effect.tryPromise`'s signal into the Promise bridge rather than leaving each query on a detached root fiber. The oxfmt binding falls back to runtime libc detection when the `SUPABASE_LIBC` build define is absent, so source runs and development binaries on musl no longer select the glibc binding. The TLS suggestion no longer prescribes `?`, which is wrong for a DSN that already carries a query string and for keyword-style connection strings. `SIDE_EFFECTS.md` now records the config, dotenv, libpq service file and `PG*` inputs that `--db-url` reads through the shared resolver. Co-Authored-By: Claude Opus 5 --- .../src/command-internal/connect-errors.ts | 2 +- .../src/commands/gen/types/SIDE_EFFECTS.md | 39 +- .../gen/types/types.generator.layer.ts | 12 +- .../src/commands/gen/types/types.handler.ts | 14 +- .../gen/types/types.integration.test.ts | 2995 +++++++++-------- .../cli/src/commands/gen/types/types.oxfmt.ts | 23 +- .../src/commands/gen/types/types.shared.ts | 4 +- .../src/commands/gen/types/types.unit.test.ts | 28 +- apps/cli/tsconfig.types.json | 2 +- 9 files changed, 1629 insertions(+), 1490 deletions(-) diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 0af0dc678a..d9eb65d081 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -372,7 +372,7 @@ export function connectSuggestion( // An unset `sslmode` negotiates TLS and fails rather than downgrading, so a server without // TLS needs the caller to opt into plaintext explicitly. if (text.includes(SERVER_REFUSED_SSL) || text.includes("server refused TLS connection")) { - return "This server does not accept TLS. Append `?sslmode=disable` to the connection string to connect in plaintext."; + return "This server does not accept TLS. Set `sslmode=disable` on the connection string to connect in plaintext."; } // Node system errors carry the dialed address as a structured field instead of libpq's // parenthesized literal, so also consult the errno + `address` classifier. diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 07b496be85..97f5bdfcdb 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -10,12 +10,14 @@ is on, `--local` resolves the project stack through `DbConfigResolver` ## Files Read -| Path | Format | When | -| ------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | -| `/supabase/config.toml` or `config.json` | TOML/JSON | when selecting schemas (`--linked`, `--project-id`, `--db-url`, and the implicit linked fallback — but not when `--schema` is also given on the two flag paths, which skip the load entirely). `--local` reads config.toml through its own tolerant reader (`readDbToml`) and always keeps the embedded-default fallback when the file is absent. On the other paths, a DEFAULTED workdir also keeps the embedded-default fallback (`included_schemas` falls back to `public,graphql_public`); an EXPLICIT `--workdir`/`SUPABASE_WORKDIR` that holds no project instead FAILS (`GenTypesMissingProjectConfigError`) rather than silently generating a `public`-only types file — see the exit-code table | -| `{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the CLI | -| `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | +| Path | Format | When | +| --------------------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | +| `/supabase/config.toml` or `config.json` | TOML/JSON | when selecting schemas (`--linked`, `--project-id`, and the implicit linked fallback — but not when `--schema` is also given on the two explicit flag paths, which skip this load entirely) or when `--db-url` needs schema defaults (`--schema` omitted). `--local` reads config.toml through its own tolerant reader (`readDbToml`) and always keeps the embedded-default fallback when the file is absent. On the other paths, a DEFAULTED workdir also keeps the embedded-default fallback (`included_schemas` falls back to `public,graphql_public`); an EXPLICIT `--workdir`/`SUPABASE_WORKDIR` that holds no project instead FAILS (`GenTypesMissingProjectConfigError`) rather than silently generating a `public`-only types file — see the exit-code table | +| `/supabase/config.toml` (`[db]` subtree only, never `config.json`) | TOML | every `--db-url` resolution, regardless of `--schema` — `DbConfigResolver` reads it through `readDbToml` to fill a passwordless local DSN's `[db].password` and to classify the target host as local vs. remote. A present-but-unparseable `config.toml` fails the command (`DbConfigLoadError`) even on this path, independent of the schema-selecting load above. | +| `{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the CLI. Every `--db-url` resolution also reads it (same `.env..local`/`.env.local`/`.env.`/`.env` search) to populate the `PG*` fallbacks used while parsing the DSN. | +| `~/.pg_service.conf` (or a `PGSERVICEFILE`/DSN `servicefile=` override) | libpq service file | `--db-url`, only when the DSN or `PGSERVICE` names a `service` — its settings fill in for connection fields the DSN itself omits | +| `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | ## Files Written @@ -63,18 +65,19 @@ inspects a container; it resolves the stack's database connection through ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | -| `SUPABASE_DB_PORT` | local database port | no (defaults to `54322`) | -| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | -| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | -| `SUPABASE_ENV` | selects nested dotenv files for local generation | no (defaults to `development`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `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 to reach the local database on the legacy Docker Compose stack | no (defaults to `127.0.0.1`) | -| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched** | +| Variable | Purpose | Required? | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for linked/project-id mode | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | local Docker container and network project ID | no (falls back to the workdir name) | +| `SUPABASE_DB_PORT` | local database port | no (defaults to `54322`) | +| `SUPABASE_DB_MAJOR_VERSION` | local PostgreSQL major version | no (defaults to `17`) | +| `SUPABASE_API_SCHEMAS` | local schemas used when `--schema` is omitted | no (defaults to `public,graphql_public`) | +| `SUPABASE_ENV` | selects nested dotenv files for local generation and `--db-url` resolution | no (defaults to `development`) | +| `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`, `PGSSLKEY`, `PGSSLPASSWORD`, `PGCONNECT_TIMEOUT`, `PGSERVICE`, `PGSERVICEFILE`, `PGAPPNAME` | libpq connection-setting fallbacks `--db-url` consults for whatever the DSN itself omits | no — each applies only when the DSN, a resolved `service`, and (where applicable) `[db].password` leave the setting unset | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `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 to reach the local database on the legacy Docker Compose stack | no (defaults to `127.0.0.1`) | +| `SUPABASE_WORKDIR` | working directory `supabase/config.toml`/`config.json` is read from (`--workdir` takes priority) | no — when unset, the CLI walks up from cwd looking for `supabase/config.toml`; when SET (flag or env) the directory is used exactly as given and **no ancestor is searched** | ## Exit Codes diff --git a/apps/cli/src/commands/gen/types/types.generator.layer.ts b/apps/cli/src/commands/gen/types/types.generator.layer.ts index ebfa400881..20e4be5117 100644 --- a/apps/cli/src/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/commands/gen/types/types.generator.layer.ts @@ -42,11 +42,15 @@ export const genTypesGeneratorLayer = Layer.effect( // context (rather than a bare detached `Effect.runPromise`) keeps the Promise bridge // anchored to this generator effect instead of a disconnected top-level runtime. const runQuery = Effect.runPromiseWith(yield* Effect.context()); - const queryable: Queryable = { - query: (sql) => runQuery(session.query(sql)).then((rows) => ({ rows: [...rows] })), - }; return yield* Effect.tryPromise({ - try: async () => { + try: async (signal) => { + // `signal` aborts when this generate call is interrupted, so forwarding it to + // every `runQuery` stops an in-flight introspection query instead of leaving it + // detached from the fiber that started it. + const queryable: Queryable = { + query: (sql) => + runQuery(session.query(sql), { signal }).then((rows) => ({ rows: [...rows] })), + }; const metadata = sortGeneratorMetadata( await introspect(queryable, { includedSchemas: [...input.includedSchemas] }), ); diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 55f5d7dd8c..2c11723e33 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -55,7 +55,7 @@ import { defaultSchemas, localDbContainerId, localDbPassword, - parseQueryTimeoutSeconds, + parseQueryTimeoutMillis, rootCaBundle, } from "./types.shared.ts"; @@ -236,7 +236,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) // Parsed before the telemetry context is installed, so an invalid `--query-timeout` wins // over every guard below and, unlike them, is never followed by a telemetry flush. - const queryTimeoutSeconds = yield* parseQueryTimeoutSeconds(flags.queryTimeout); + const queryTimeoutMillis = yield* parseQueryTimeoutMillis(flags.queryTimeout); const schemas = flags.schema; const lang = flags.lang; @@ -292,8 +292,14 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) */ const withQueryTimeout = (conn: PgConnInput): PgConnInput => ({ ...conn, - runtimeParams: { ...conn.runtimeParams, statement_timeout: String(queryTimeoutSeconds * 1000) }, - connectTimeoutSeconds: queryTimeoutSeconds, + runtimeParams: { + ...conn.runtimeParams, + statement_timeout: + queryTimeoutMillis === 0 ? "0" : String(Math.max(1, Math.round(queryTimeoutMillis))), + }, + ...(queryTimeoutMillis === 0 + ? {} + : { connectTimeoutSeconds: Math.max(1, Math.ceil(queryTimeoutMillis / 1000)) }), }); const runGenerate = (input: { diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 0da724edc8..84ad23e2bd 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -44,7 +44,7 @@ import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import type { GenTypesFlags } from "./types.command.ts"; import { genTypes } from "./types.handler.ts"; -import { localDbContainerId, parseQueryTimeoutSeconds, rootCaBundle } from "./types.shared.ts"; +import { localDbContainerId, parseQueryTimeoutMillis, rootCaBundle } from "./types.shared.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { GenTypesGenerationError, @@ -457,8 +457,8 @@ const nonTypescriptProjectRefScenarios = [ describe("gen types", () => { it.effect("accepts Go-style microsecond duration aliases", () => Effect.gen(function* () { - expect(yield* parseQueryTimeoutSeconds(`15${"µ"}s`)).toBe(0); - expect(yield* parseQueryTimeoutSeconds(`15${"μ"}s`)).toBe(0); + expect(yield* parseQueryTimeoutMillis(`15${"µ"}s`)).toBe(0.015); + expect(yield* parseQueryTimeoutMillis(`15${"μ"}s`)).toBe(0.015); }), ); @@ -847,352 +847,341 @@ describe("gen types", () => { }); }); - // --- Flag mutex groups and argv-scan precedence ----------------------------------------- + describe("Flag mutex groups and argv-scan precedence", () => { + it.live("rejects combining --local and --linked", () => { + const { layer, telemetry } = setup({ args: ["gen", "types", "--local", "--linked"] }); - it.live("rejects combining --local and --linked", () => { - const { layer, telemetry } = setup({ args: ["gen", "types", "--local", "--linked"] }); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true, linked: 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 [local linked project-id db-url] are set none of the others can be; [linked local] were all set", + ); + } + expect(telemetry.flushed).toBe(true); + }); + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "if any flags in the group [local linked project-id db-url] are set none of the others can be; [linked local] were all set", + it.live("does not misdetect a mutex flag consumed as -s's value (pflag consumption)", () => { + // `childExitCode: 1` fails the local target's `container inspect`, keeping the + // downstream failure deterministic once `--linked` is consumed as `-s`'s value. + const { layer } = setup({ + args: ["gen", "types", "-s", "--linked", "--local"], + childExitCode: 1, + }); + + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( + Effect.provide(layer), + Effect.exit, ); - } - expect(telemetry.flushed).toBe(true); - }); - }); - it.live("does not misdetect a mutex flag consumed as -s's value (pflag consumption)", () => { - // `childExitCode: 1` fails the local target's `container inspect`, keeping the - // downstream failure deterministic once `--linked` is consumed as `-s`'s value. - const { layer } = setup({ - args: ["gen", "types", "-s", "--linked", "--local"], - childExitCode: 1, + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("failed to inspect service"); + expect(String(exit.cause)).not.toContain("if any flags in the group"); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( - Effect.provide(layer), - Effect.exit, - ); + it.live("rejects --swift-access-control with --linked (cobra mutex group)", () => { + const { layer } = setup({ + args: ["gen", "types", "--linked", "--swift-access-control", "public", "--lang", "swift"], + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("failed to inspect service"); - expect(String(exit.cause)).not.toContain("if any flags in the group"); - } - }); - }); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ linked: true, lang: "swift", swiftAccessControl: "public" }), + ).pipe(Effect.provide(layer), Effect.exit); - it.live("rejects --swift-access-control with --linked (cobra mutex group)", () => { - const { layer } = setup({ - args: ["gen", "types", "--linked", "--swift-access-control", "public", "--lang", "swift"], + 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", + ); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ linked: true, lang: "swift", swiftAccessControl: "public" }), - ).pipe(Effect.provide(layer), Effect.exit); + it.live("rejects --swift-access-control with --project-id (cobra mutex group)", () => { + const { layer } = setup({ + args: [ + "gen", + "types", + "--project-id", + VALID_REF, + "--swift-access-control", + "public", + "--lang", + "swift", + ], + }); - 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", - ); - } - }); - }); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "swift", + swiftAccessControl: "public", + }), + ).pipe(Effect.provide(layer), Effect.exit); - it.live("rejects --swift-access-control with --project-id (cobra mutex group)", () => { - const { layer } = setup({ - args: [ - "gen", - "types", - "--project-id", - VALID_REF, - "--swift-access-control", - "public", - "--lang", - "swift", - ], + 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; [project-id swift-access-control] were all set", + ); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "swift", - swiftAccessControl: "public", - }), - ).pipe(Effect.provide(layer), Effect.exit); + it.live("rejects --postgrest-v9-compat without --db-url for project-id generation", () => { + const { layer } = setup({ + args: ["gen", "types", "--project-id", VALID_REF, "--postgrest-v9-compat"], + }); - 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; [project-id swift-access-control] were all set", - ); - } - }); - }); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), postgrestV9Compat: true }), + ).pipe(Effect.provide(layer), Effect.exit); - it.live("rejects --postgrest-v9-compat without --db-url for project-id generation", () => { - const { layer } = setup({ - args: ["gen", "types", "--project-id", VALID_REF, "--postgrest-v9-compat"], + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Established guard, including its "must used" typo — do not "fix" the grammar. + expect(String(exit.cause)).toContain( + "--postgrest-v9-compat must used together with --db-url", + ); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), postgrestV9Compat: true }), - ).pipe(Effect.provide(layer), Effect.exit); + it.live("rejects --postgrest-v9-compat without --db-url for local generation", () => { + const { layer, telemetry } = setup({ + args: ["gen", "types", "--local", "--postgrest-v9-compat"], + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // Established guard, including its "must used" typo — do not "fix" the grammar. - expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --db-url", + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true, postgrestV9Compat: true })).pipe( + Effect.provide(layer), + Effect.exit, ); - } - }); - }); - it.live("rejects --postgrest-v9-compat without --db-url for local generation", () => { - const { layer, telemetry } = setup({ - args: ["gen", "types", "--local", "--postgrest-v9-compat"], + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "--postgrest-v9-compat must used together with --db-url", + ); + } + expect(telemetry.flushed).toBe(true); + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true, postgrestV9Compat: true })).pipe( - Effect.provide(layer), - Effect.exit, - ); + it.live("rejects --query-timeout with --project-id (cobra mutex group)", () => { + const { layer } = setup({ + args: ["gen", "types", "--project-id", VALID_REF, "--query-timeout", "20s"], + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --db-url", - ); - } - expect(telemetry.flushed).toBe(true); - }); - }); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), queryTimeout: "20s" }), + ).pipe(Effect.provide(layer), Effect.exit); - it.live("rejects --query-timeout with --project-id (cobra mutex group)", () => { - const { layer } = setup({ - args: ["gen", "types", "--project-id", VALID_REF, "--query-timeout", "20s"], + 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; [project-id query-timeout] were all set", + ); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), queryTimeout: "20s" }), - ).pipe(Effect.provide(layer), Effect.exit); + it.live("rejects --query-timeout with --linked (cobra mutex group)", () => { + const { layer } = setup({ + args: ["gen", "types", "--linked", "--query-timeout", "20s"], + projectId: Option.some(VALID_REF), + }); - 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; [project-id query-timeout] were all set", + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ linked: true, queryTimeout: "20s" })).pipe( + Effect.provide(layer), + Effect.exit, ); - } - }); - }); - it.live("rejects --query-timeout with --linked (cobra mutex group)", () => { - const { layer } = setup({ - args: ["gen", "types", "--linked", "--query-timeout", "20s"], - projectId: Option.some(VALID_REF), + 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", + ); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ linked: true, queryTimeout: "20s" })).pipe( - Effect.provide(layer), - Effect.exit, - ); + it.live("counts explicitly negated booleans as set for mutex groups (pflag Changed)", () => { + const { layer } = setup({ + args: ["gen", "types", "--linked=false", "--project-id", VALID_REF], + }); - 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", - ); - } - }); - }); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ linked: false, projectId: Option.some(VALID_REF) }), + ).pipe(Effect.provide(layer), Effect.exit); - it.live("counts explicitly negated booleans as set for mutex groups (pflag Changed)", () => { - const { layer } = setup({ - args: ["gen", "types", "--linked=false", "--project-id", VALID_REF], + 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 project-id] were all set", + ); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ linked: false, projectId: Option.some(VALID_REF) }), - ).pipe(Effect.provide(layer), Effect.exit); + it.live("fails on an invalid --query-timeout before any flag guard runs", () => { + const { layer, telemetry } = setup({ + args: ["gen", "types", "--linked", "--query-timeout", "bogus"], + }); - 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 project-id] were all set", + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ linked: true, queryTimeout: "bogus" })).pipe( + Effect.provide(layer), + Effect.exit, ); - } - }); - }); - it.live("fails on an invalid --query-timeout before any flag guard runs", () => { - const { layer, telemetry } = setup({ - args: ["gen", "types", "--linked", "--query-timeout", "bogus"], + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain('invalid duration "bogus"'); + expect(String(exit.cause)).not.toContain("if any flags in the group"); + } + expect(telemetry.flushed).toBe(false); + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ linked: true, queryTimeout: "bogus" })).pipe( - Effect.provide(layer), - Effect.exit, - ); + it.live("prefers the --postgrest-v9-compat guard over mutex group errors", () => { + const { layer } = setup({ + args: ["gen", "types", "--local", "--linked", "--postgrest-v9-compat"], + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain('invalid duration "bogus"'); - expect(String(exit.cause)).not.toContain("if any flags in the group"); - } - expect(telemetry.flushed).toBe(false); - }); - }); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ local: true, linked: true, postgrestV9Compat: true }), + ).pipe(Effect.provide(layer), Effect.exit); - it.live("prefers the --postgrest-v9-compat guard over mutex group errors", () => { - const { layer } = setup({ - args: ["gen", "types", "--local", "--linked", "--postgrest-v9-compat"], + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "--postgrest-v9-compat must used together with --db-url", + ); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ local: true, linked: true, postgrestV9Compat: true }), - ).pipe(Effect.provide(layer), Effect.exit); + it.live("prefers the positional language guard over mutex group errors", () => { + const { layer } = setup({ + args: ["gen", "types", "go", "--local", "--linked"], + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "--postgrest-v9-compat must used together with --db-url", + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( + Effect.provide(layer), + Effect.exit, ); - } - }); - }); - it.live("prefers the positional language guard over mutex group errors", () => { - const { layer } = setup({ - args: ["gen", "types", "go", "--local", "--linked"], + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true, linked: true })).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } - }); - }); - - it.live("reports mutex groups in cobra's sorted group-key order", () => { - const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; - const { layer } = setup({ - args: ["gen", "types", "--db-url", dbUrl, "--postgrest-v9-compat", "--project-id", VALID_REF], - }); + it.live("reports mutex groups in cobra's sorted group-key order", () => { + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const { layer } = setup({ + args: [ + "gen", + "types", + "--db-url", + dbUrl, + "--postgrest-v9-compat", + "--project-id", + VALID_REF, + ], + }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ - dbUrl: Option.some(dbUrl), - projectId: Option.some(VALID_REF), - postgrestV9Compat: true, - }), - ).pipe(Effect.provide(layer), Effect.exit); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ + dbUrl: Option.some(dbUrl), + projectId: Option.some(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; [postgrest-v9-compat project-id] were all set", - ); - } + 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; [postgrest-v9-compat project-id] were all set", + ); + } + }); }); - }); - it.live("rejects a non-typescript language passed after a -- separator", () => { - const { layer } = setup({ args: ["gen", "types", "--", "go"] }); - - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } - }); - }); + it.live("rejects a non-typescript language passed after a -- separator", () => { + const { layer } = setup({ args: ["gen", "types", "--", "go"] }); - it.live("treats a trailing -- with no operand as no positional language", () => { - const { layer, api } = setup({ - args: ["gen", "types", "--"], - projectId: Option.some(VALID_REF), - projectTypes: "ok", - }); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - return Effect.gen(function* () { - yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); - expect(api.requests).toHaveLength(1); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); }); - }); - it.live("treats a positional after a valueless long flag as the language", () => { - const { layer } = setup({ args: ["gen", "types", "--local", "go"] }); + it.live("treats a trailing -- with no operand as no positional language", () => { + const { layer, api } = setup({ + args: ["gen", "types", "--"], + projectId: Option.some(VALID_REF), + projectTypes: "ok", + }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } + return Effect.gen(function* () { + yield* genTypes(defaultFlags()).pipe(Effect.provide(layer)); + expect(api.requests).toHaveLength(1); + }); }); - }); - it.live("treats a positional after a valueless short flag as the language", () => { - const { layer } = setup({ args: ["gen", "types", "-x", "go"] }); + it.live("treats a positional after a valueless long flag as the language", () => { + const { layer } = setup({ args: ["gen", "types", "--local", "go"] }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } - }); - }); - - it.live("rejects legacy positional non-typescript without an explicit lang flag", () => { - const { layer } = setup({ - args: ["gen", "types", "go"], + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); + it.live("treats a positional after a valueless short flag as the language", () => { + const { layer } = setup({ args: ["gen", "types", "-x", "go"] }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); - } + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); }); - }); - it.live( - "rejects legacy positional non-typescript after consuming short flags with values", - () => { + it.live("rejects legacy positional non-typescript without an explicit lang flag", () => { const { layer } = setup({ - args: ["gen", "types", "-o", "json", "go"], - goOutput: Option.some("json"), + args: ["gen", "types", "go"], }); return Effect.gen(function* () { @@ -1203,37 +1192,32 @@ describe("gen types", () => { expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); } }); - }, - ); - - // --- --network-id is a hard error on every natively-generated path --------------------- - - it.live("rejects --network-id after the gen types command path", () => { - const { layer, generator, child } = setup({ - args: ["gen", "types", "--local", "--network-id", "net"], }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.exit, - ); + it.live( + "rejects legacy positional non-typescript after consuming short flags with values", + () => { + const { layer } = setup({ + args: ["gen", "types", "-o", "json", "go"], + goOutput: Option.some("json"), + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("GenTypesNetworkIdUnsupportedError"); - expect(String(exit.cause)).toContain("cannot join a Docker network via --network-id"); - } - expect(generator.calls).toHaveLength(0); - expect(child.calls).toHaveLength(0); - }); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags()).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("use --lang flag to specify the typegen language"); + } + }); + }, + ); }); - it.live( - "rejects a persistent --network-id set before the command path (supabase --network-id net gen types --local)", - () => { + describe("--network-id is a hard error on every natively-generated path", () => { + it.live("rejects --network-id after the gen types command path", () => { const { layer, generator, child } = setup({ - args: ["--network-id", "net", "gen", "types", "--local"], + args: ["gen", "types", "--local", "--network-id", "net"], }); return Effect.gen(function* () { @@ -1250,495 +1234,447 @@ describe("gen types", () => { expect(generator.calls).toHaveLength(0); expect(child.calls).toHaveLength(0); }); - }, - ); + }); - // --- Non-TypeScript generation through the DB resolver + native generator -------------- + it.live( + "rejects a persistent --network-id set before the command path (supabase --network-id net gen types --local)", + () => { + const { layer, generator, child } = setup({ + args: ["--network-id", "net", "gen", "types", "--local"], + }); - for (const scenario of nonTypescriptProjectRefScenarios) { - it.live(`generates ${scenario.lang} types from a project ref through the DB resolver`, () => { - const { layer, out, api, linkedProjectCache, dbConfig, generator } = setup({ - args: ["gen", "types", "--lang", scenario.lang, "--project-id", VALID_REF], - generatorOutput: scenario.stdout, - dbConfigResolve: (input) => - Effect.succeed( - remoteResolvedConfig( - { - host: "127.0.0.1", - port: 5432, - user: `cli_login_${VALID_REF}`, - password: "temporary-password", - database: "postgres", - }, - (input.linkedProjectRef !== undefined - ? Option.getOrUndefined(input.linkedProjectRef) - : undefined) ?? VALID_REF, + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("GenTypesNetworkIdUnsupportedError"); + expect(String(exit.cause)).toContain("cannot join a Docker network via --network-id"); + } + expect(generator.calls).toHaveLength(0); + expect(child.calls).toHaveLength(0); + }); + }, + ); + }); + + describe("Non-TypeScript generation through the DB resolver + native generator", () => { + for (const scenario of nonTypescriptProjectRefScenarios) { + it.live(`generates ${scenario.lang} types from a project ref through the DB resolver`, () => { + const { layer, out, api, linkedProjectCache, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", scenario.lang, "--project-id", VALID_REF], + generatorOutput: scenario.stdout, + dbConfigResolve: (input) => + Effect.succeed( + remoteResolvedConfig( + { + host: "127.0.0.1", + port: 5432, + user: `cli_login_${VALID_REF}`, + password: "temporary-password", + database: "postgres", + }, + (input.linkedProjectRef !== undefined + ? Option.getOrUndefined(input.linkedProjectRef) + : undefined) ?? VALID_REF, + ), ), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.fail(new Error(`unexpected preview branch lookup for ${branch_id_or_ref}`)), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + }); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: scenario.lang, + }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ method: "getProject", input: { ref: 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(out.stderrText).toContain("Connecting to 127.0.0.1 5432"); + expect(out.stdoutText).toContain(scenario.stdout); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); + const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; + expect( + linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, + ).toBe(VALID_REF); + expect(generator.calls).toHaveLength(1); + const call = generator.calls[0]; + expect(call?.lang).toBe(scenario.lang); + expect(call?.includedSchemas).toEqual(["public"]); + expect(call?.isLocal).toBe(false); + // project-ref generation always pins the Supabase CA, promoting sslmode to verify-ca. + expect(call?.conn.sslmode).toBe("require"); + expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); + expect(linkedProjectCache.cached).toBe(true); + }); + }); + } + + it.live("resolves the linked workdir DB without ad-hoc project-ref semantics", () => { + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--linked"], + projectId: Option.some(VALID_REF), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port: 5432, + user: "postgres", + password: "workdir-password", + database: "postgres", + }), ), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.fail(new Error(`unexpected preview branch lookup for ${branch_id_or_ref}`)), - createLoginRole: ({ ref }) => - Effect.fail(new Error(`unexpected login role creation for ${ref}`)), }); return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: scenario.lang, - }), - ).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)); - expect(api.requests).toContainEqual({ method: "getProject", input: { ref: 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(out.stderrText).toContain("Connecting to 127.0.0.1 5432"); - expect(out.stdoutText).toContain(scenario.stdout); expect(dbConfig.resolves).toHaveLength(1); expect(dbConfig.resolves[0]?.connType).toBe("linked"); - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); - const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; - expect( - linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, - ).toBe(VALID_REF); - expect(generator.calls).toHaveLength(1); - const call = generator.calls[0]; - expect(call?.lang).toBe(scenario.lang); - expect(call?.includedSchemas).toEqual(["public"]); - expect(call?.isLocal).toBe(false); - // project-ref generation always pins the Supabase CA, promoting sslmode to verify-ca. - expect(call?.conn.sslmode).toBe("require"); - expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); - expect(linkedProjectCache.cached).toBe(true); + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); }); }); - } - - it.live("resolves the linked workdir DB without ad-hoc project-ref semantics", () => { - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--linked"], - projectId: Option.some(VALID_REF), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port: 5432, - user: "postgres", - password: "workdir-password", - database: "postgres", - }), - ), - }); - - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)); - - expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("linked"); - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); - }); - }); - - it.live("preserves resolver connection options for remote non-TypeScript typegen", () => { - const { layer, generator } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port: 5432, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - options: `reference=${VALID_REF}`, - }), - ), - }); - - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)); - - expect(generator.calls[0]?.conn.options).toBe(`reference=${VALID_REF}`); - expect(generator.calls[0]?.conn.user).toBe(`postgres.${VALID_REF}`); - }); - }); - it.live( - "forwards --query-timeout and --swift-access-control to the generator for implicit linked non-TypeScript generation", - () => { - const { layer, dbConfig, generator } = setup({ - args: [ - "gen", - "types", - "--lang", - "go", - "--query-timeout", - "20s", - "--swift-access-control", - "public", - ], - projectId: Option.some(VALID_REF), + it.live("preserves resolver connection options for remote non-TypeScript typegen", () => { + const { layer, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + options: `reference=${VALID_REF}`, + }), + ), }); return Effect.gen(function* () { yield* genTypes( - defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "go", + }), ).pipe(Effect.provide(layer)); - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); - const call = generator.calls[0]; - expect(call?.swiftAccessControl).toBe("public"); - expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); - expect(call?.conn.connectTimeoutSeconds).toBe(20); + expect(generator.calls[0]?.conn.options).toBe(`reference=${VALID_REF}`); + expect(generator.calls[0]?.conn.user).toBe(`postgres.${VALID_REF}`); }); - }, - ); - - it.live("uses remote config schemas for explicit project-ref typegen", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-remote-config-")); - writeConfig( - workdir, - [ - 'project_id = "base"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[remotes.staging]", - `project_id = "${VALID_REF}"`, - "", - "[remotes.staging.api]", - 'schemas = ["private"]', - "", - ].join("\n"), - ); - const { layer, generator } = setup({ - workdir, - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ + it.live( + "forwards --query-timeout and --swift-access-control to the generator for implicit linked non-TypeScript generation", + () => { + const { layer, dbConfig, generator } = setup({ + args: [ + "gen", + "types", + "--lang", + "go", + "--query-timeout", + "20s", + "--swift-access-control", + "public", + ], projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)); + }); - expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private"]); - }); - }); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), + ).pipe(Effect.provide(layer)); - it.live("uses remote config schemas for linked typegen", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-linked-config-")); - writeConfig( - workdir, - [ - 'project_id = "base"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[remotes.staging]", - `project_id = "${VALID_REF}"`, - "", - "[remotes.staging.api]", - 'schemas = ["private"]', - "", - ].join("\n"), + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + const call = generator.calls[0]; + expect(call?.swiftAccessControl).toBe("public"); + expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); + expect(call?.conn.connectTimeoutSeconds).toBe(20); + }); + }, ); - const { layer, generator } = setup({ - workdir, - projectId: Option.some(VALID_REF), - args: ["gen", "types", "--lang", "go", "--linked"], - }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - linked: true, - lang: "go", - }), - ).pipe(Effect.provide(layer)); - - expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private"]); - }); - }); - - // --- Preview-branch fallback ------------------------------------------------------------- - - it.live("falls back to preview branch config for non-TypeScript project refs", () => { - const { layer, api, dbConfig, generator } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], - generatorOutput: "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: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - createLoginRole: ({ ref }) => - Effect.fail(new Error(`unexpected login role creation for ${ref}`)), - }); + it.live("uses remote config schemas for explicit project-ref typegen", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-remote-config-")); + writeConfig( + workdir, + [ + 'project_id = "base"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[remotes.staging]", + `project_id = "${VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const { layer, generator } = setup({ + workdir, + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); - expect(api.requests).toContainEqual({ method: "getProject", input: { ref: VALID_REF } }); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: VALID_REF }, + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private"]); }); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "createLoginRole" }), - ); - expect(dbConfig.resolves).toHaveLength(0); - const call = generator.calls[0]; - expect(call?.conn.host).toBe("127.0.0.1"); - expect(call?.conn.user).toBe("branch_user"); - expect(call?.conn.password).toBe("branch-password"); - // Preview-branch generation pins the Supabase CA the same as any other remote target. - expect(call?.conn.sslmode).toBe("require"); - expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); }); - }); - it.live("falls back to preview branch config for any project 404 body", () => { - const { layer, api, dbConfig, generator } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], - generatorOutput: "class PublicMovies(BaseModel):", - 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: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - }); + it.live("uses remote config schemas for linked typegen", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-linked-config-")); + writeConfig( + workdir, + [ + 'project_id = "base"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[remotes.staging]", + `project_id = "${VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const { layer, generator } = setup({ + workdir, + projectId: Option.some(VALID_REF), + args: ["gen", "types", "--lang", "go", "--linked"], + }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + linked: true, + lang: "go", + }), + ).pipe(Effect.provide(layer)); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: VALID_REF }, + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private"]); }); - expect(dbConfig.resolves).toHaveLength(0); - expect(generator.calls[0]?.conn.password).toBe("branch-password"); }); }); - it.live("fails clearly when preview branch config does not include DB credentials", () => { - const { layer } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", 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", - }), - }); + describe("Preview-branch fallback", () => { + it.live("falls back to preview branch config for non-TypeScript project refs", () => { + const { layer, api, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + generatorOutput: "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: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer), Effect.exit); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("Preview branch database credentials are unavailable"); - } + expect(api.requests).toContainEqual({ method: "getProject", input: { ref: VALID_REF } }); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(dbConfig.resolves).toHaveLength(0); + const call = generator.calls[0]; + expect(call?.conn.host).toBe("127.0.0.1"); + expect(call?.conn.user).toBe("branch_user"); + expect(call?.conn.password).toBe("branch-password"); + // Preview-branch generation pins the Supabase CA the same as any other remote target. + expect(call?.conn.sslmode).toBe("require"); + expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); + }); }); - }); - // --- Pooler fallback on an IPv6-classified generation failure --------------------------- - - it.live("retries through the IPv4 pooler on an IPv6-classified generation failure", () => { - const poolerConn: PgConnInput = { - host: "127.0.0.1", - port: 5432, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }; - const generator = sequentialGenerator([ - () => Effect.fail(ipv6Failure()), - () => Effect.succeed("type RetriedViaPooler struct {}"), - ]); - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - generator, - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port: 5432, - user: "postgres", - password: "direct-password", - database: "postgres", + it.live("falls back to preview branch config for any project 404 body", () => { + const { layer, api, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + generatorOutput: "class PublicMovies(BaseModel):", + 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: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", }), - ), - poolerFallback: Option.some(poolerConn), - }); + }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); - expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); - expect(out.stderrText).toContain("does not support IPv6"); - expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); - expect(generator.calls).toHaveLength(2); - expect(generator.calls[0]?.conn.host).toBe(`db.${VALID_REF}.supabase.co`); - expect(generator.calls[1]?.conn.host).toBe("127.0.0.1"); - expect(dbConfig.poolerFallbacks).toHaveLength(1); - expect(dbConfig.poolerFallbacks[0]?.connType).toBe("linked"); - expect(dbConfig.poolerFallbacks[0]?.adHocProjectRef).toBe(true); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: VALID_REF }, + }); + expect(dbConfig.resolves).toHaveLength(0); + expect(generator.calls[0]?.conn.password).toBe("branch-password"); + }); }); - }); - it.live("does not retry through the pooler when the failure is not IPv6-classified", () => { - const generator = sequentialGenerator([() => Effect.fail(nonIpv6Failure())]); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - generator, - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port: 5432, - user: "postgres", - password: "direct-password", - database: "postgres", + it.live("fails clearly when preview branch config does not include DB credentials", () => { + const { layer } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", 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", }), - ), - poolerFallback: Option.some({ - host: "127.0.0.1", - port: 5432, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); + }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), - ).pipe(Effect.provide(layer), Effect.exit); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(generator.calls).toHaveLength(1); - expect(dbConfig.poolerFallbacks).toHaveLength(0); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "Preview branch database credentials are unavailable", + ); + } + }); }); }); - it.live("does not run pooler fallback a second time when the retry also fails IPv6-style", () => { - const generator = sequentialGenerator([ - () => Effect.fail(ipv6Failure()), - () => Effect.fail(ipv6Failure()), - ]); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - generator, - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port: 5432, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ + describe("Pooler fallback on an IPv6-classified generation failure", () => { + it.live("retries through the IPv4 pooler on an IPv6-classified generation failure", () => { + const poolerConn: PgConnInput = { host: "127.0.0.1", port: 5432, user: `postgres.${VALID_REF}`, password: "pooler-password", database: "postgres", - }), - }); + }; + const generator = sequentialGenerator([ + () => Effect.fail(ipv6Failure()), + () => Effect.succeed("type RetriedViaPooler struct {}"), + ]); + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some(poolerConn), + }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), - ).pipe(Effect.provide(layer), Effect.exit); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); - expect(Exit.isFailure(exit)).toBe(true); - expect(generator.calls).toHaveLength(2); - expect(dbConfig.poolerFallbacks).toHaveLength(1); + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(out.stderrText).toContain("does not support IPv6"); + expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); + expect(generator.calls).toHaveLength(2); + expect(generator.calls[0]?.conn.host).toBe(`db.${VALID_REF}.supabase.co`); + expect(generator.calls[1]?.conn.host).toBe("127.0.0.1"); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + expect(dbConfig.poolerFallbacks[0]?.connType).toBe("linked"); + expect(dbConfig.poolerFallbacks[0]?.adHocProjectRef).toBe(true); + }); }); - }); - it.live( - "does not retry through the pooler when the resolved connection is already a pooler host", - () => { - const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); - const { layer, out, dbConfig } = setup({ + it.live("does not retry through the pooler when the failure is not IPv6-classified", () => { + const generator = sequentialGenerator([() => Effect.fail(nonIpv6Failure())]); + const { layer, dbConfig } = setup({ args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], generator, dbConfigResolve: () => Effect.succeed( remoteResolvedConfig({ - host: "aws-0-us-east-1.pooler.supabase.com", + host: `db.${VALID_REF}.supabase.co`, port: 5432, - user: `postgres.${VALID_REF}`, - password: "pooler-password", + user: "postgres", + password: "direct-password", database: "postgres", }), ), poolerFallback: Option.some({ - host: "aws-0-us-east-1.pooler.supabase.com", + host: "127.0.0.1", port: 5432, user: `postgres.${VALID_REF}`, password: "pooler-password", @@ -1754,590 +1690,628 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); expect(generator.calls).toHaveLength(1); expect(dbConfig.poolerFallbacks).toHaveLength(0); - expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); }); - }, - ); + }); - it.live("preserves the original generation error when pooler fallback resolution fails", () => { - const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); - const { layer } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], - generator, - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, + it.live( + "does not run pooler fallback a second time when the retry also fails IPv6-style", + () => { + const generator = sequentialGenerator([ + () => Effect.fail(ipv6Failure()), + () => Effect.fail(ipv6Failure()), + ]); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "127.0.0.1", port: 5432, - user: "postgres", - password: "direct-password", + user: `postgres.${VALID_REF}`, + password: "pooler-password", database: "postgres", }), - ), - poolerFallbackFails: true, - }); + }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), - ).pipe(Effect.provide(layer), Effect.exit); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + ).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("No address associated with hostname"); - expect(String(exit.cause)).not.toContain("pooler fallback failed"); - } - expect(generator.calls).toHaveLength(1); + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }); + }, + ); + + it.live( + "does not retry through the pooler when the resolved connection is already a pooler host", + () => { + const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); + }); + }, + ); + + it.live("preserves the original generation error when pooler fallback resolution fails", () => { + const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); + const { layer } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallbackFails: true, + }); + + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("No address associated with hostname"); + expect(String(exit.cause)).not.toContain("pooler fallback failed"); + } + expect(generator.calls).toHaveLength(1); + }); }); - }); - it.live("retries preview branch generation through the branch IPv4 pooler", () => { - const poolerHost = "aws-0-us-east-1.pooler.supabase.com"; - const generator = sequentialGenerator([ - () => Effect.fail(ipv6Failure("python")), - () => Effect.succeed("class RetriedViaBranchPooler(BaseModel):"), - ]); - const { layer, api } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], - generator, - 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: `db.${branch_id_or_ref}.supabase.co`, - db_port: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - getPoolerConfig: ({ ref }) => - Effect.succeed([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", + it.live("retries preview branch generation through the branch IPv4 pooler", () => { + const poolerHost = "aws-0-us-east-1.pooler.supabase.com"; + const generator = sequentialGenerator([ + () => Effect.fail(ipv6Failure("python")), + () => Effect.succeed("class RetriedViaBranchPooler(BaseModel):"), + ]); + const { layer, api } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + generator, + 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: `db.${branch_id_or_ref}.supabase.co`, db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - }); + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + getPoolerConfig: ({ ref }) => + Effect.succeed([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); - expect(api.requests).toContainEqual({ - method: "getPoolerConfig", - input: { ref: VALID_REF }, + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: VALID_REF }, + }); + expect(generator.calls).toHaveLength(2); + expect(generator.calls[1]?.conn.host).toBe(poolerHost); + expect(generator.calls[1]?.conn.password).toBe("branch-password"); }); - expect(generator.calls).toHaveLength(2); - expect(generator.calls[1]?.conn.host).toBe(poolerHost); - expect(generator.calls[1]?.conn.password).toBe("branch-password"); }); - }); - it.live("skips preview branch pooler fallback when the pooler URL fails validation", () => { - const generator = sequentialGenerator([() => Effect.fail(ipv6Failure("python"))]); - const { layer, api } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], - generator, - 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: `db.${branch_id_or_ref}.supabase.co`, - db_port: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - getPoolerConfig: ({ ref }) => - Effect.succeed([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", + it.live("skips preview branch pooler fallback when the pooler URL fails validation", () => { + const generator = sequentialGenerator([() => Effect.fail(ipv6Failure("python"))]); + const { layer, api } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], + generator, + 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: `db.${branch_id_or_ref}.supabase.co`, db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - }); + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + getPoolerConfig: ({ ref }) => + Effect.succeed([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + }); - return Effect.gen(function* () { - const exit = yield* genTypes( - defaultFlags({ - projectId: Option.some(VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer), Effect.exit); + return Effect.gen(function* () { + const exit = yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(api.requests).toContainEqual({ - method: "getPoolerConfig", - input: { ref: VALID_REF }, + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: VALID_REF }, + }); + expect(generator.calls).toHaveLength(1); }); - expect(generator.calls).toHaveLength(1); }); }); - // --- TLS: pin the Supabase CA only where the design calls for it ------------------------ + describe("TLS: pin the Supabase CA only where the design calls for it", () => { + it.live("pins the Supabase CA for a db-url pointing at a direct database host", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ), + }); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some( + `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres`, + ), + }), + ).pipe(Effect.provide(layer)); - it.live("pins the Supabase CA for a db-url pointing at a direct database host", () => { - const { layer, generator } = setup({ - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port: 5432, - user: "postgres", - password: "postgres", - database: "postgres", + expect(generator.calls[0]?.conn.sslmode).toBe("require"); + expect(generator.calls[0]?.conn.sslrootcertInline).toBe(rootCaBundle()); + }); + }); + + it.live("pins the Supabase CA for a db-url pointing at the pooler host", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 6543, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + ), + }); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some( + "postgresql://postgres.ref:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres", + ), }), - ), + ).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.conn.sslmode).toBe("require"); + expect(generator.calls[0]?.conn.sslrootcertInline).toBe(rootCaBundle()); + }); }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - dbUrl: Option.some( - `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres`, + it.live("honors an explicit sslmode from the db-url's DSN on a Supabase host", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + sslmode: "disable", + }), ), - }), - ).pipe(Effect.provide(layer)); - - expect(generator.calls[0]?.conn.sslmode).toBe("require"); - expect(generator.calls[0]?.conn.sslrootcertInline).toBe(rootCaBundle()); - }); - }); + }); - it.live("pins the Supabase CA for a db-url pointing at the pooler host", () => { - const { layer, generator } = setup({ - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 6543, - user: `postgres.${VALID_REF}`, - password: "pooler-password", - database: "postgres", + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some( + `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres?sslmode=disable`, + ), }), - ), + ).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.conn.sslmode).toBe("disable"); + expect(generator.calls[0]?.conn.sslrootcertInline).toBeUndefined(); + }); }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - dbUrl: Option.some( - "postgresql://postgres.ref:pw@aws-0-us-east-1.pooler.supabase.com:6543/postgres", + it.live("leaves a non-Supabase db-url host unpinned", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "db.example.net", + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }), ), - }), - ).pipe(Effect.provide(layer)); - - expect(generator.calls[0]?.conn.sslmode).toBe("require"); - expect(generator.calls[0]?.conn.sslrootcertInline).toBe(rootCaBundle()); - }); - }); + }); - it.live("honors an explicit sslmode from the db-url's DSN on a Supabase host", () => { - const { layer, generator } = setup({ - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port: 5432, - user: "postgres", - password: "postgres", - database: "postgres", - sslmode: "disable", + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@db.example.net:5432/postgres"), }), - ), + ).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.conn.sslmode).toBeUndefined(); + expect(generator.calls[0]?.conn.sslrootcertInline).toBeUndefined(); + }); }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - dbUrl: Option.some( - `postgresql://postgres:postgres@db.${VALID_REF}.supabase.co:5432/postgres?sslmode=disable`, + it.live("never pins TLS for a local db-url target", () => { + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed( + localResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "postgres", + database: "postgres", + }), ), - }), - ).pipe(Effect.provide(layer)); - - expect(generator.calls[0]?.conn.sslmode).toBe("disable"); - expect(generator.calls[0]?.conn.sslrootcertInline).toBeUndefined(); - }); - }); + }); - it.live("leaves a non-Supabase db-url host unpinned", () => { - const { layer, generator } = setup({ - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "db.example.net", - port: 5432, - user: "postgres", - password: "postgres", - database: "postgres", + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), }), - ), - }); - - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@db.example.net:5432/postgres"), - }), - ).pipe(Effect.provide(layer)); + ).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.conn.sslmode).toBeUndefined(); - expect(generator.calls[0]?.conn.sslrootcertInline).toBeUndefined(); + expect(generator.calls[0]?.conn.sslmode).toBeUndefined(); + expect(generator.calls[0]?.conn.sslrootcertInline).toBeUndefined(); + }); }); }); - it.live("never pins TLS for a local db-url target", () => { - const { layer, generator } = setup({ - dbConfigResolve: () => - Effect.succeed( - localResolvedConfig({ - host: `db.${VALID_REF}.supabase.co`, - port: 5432, + describe("Local generation: legacy backend (still inspects the Docker container)", () => { + it.live( + "generates locally via the legacy backend, connecting directly to the mapped port", + () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public", "custom"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + writeFileSync( + join(workdir, "supabase", ".env"), + "DOCKER_HOST=project-daemon\nSUPABASE_INTERNAL_IMAGE_REGISTRY=docker.io\nSUPABASE_USE_SLIM_IMAGES=1\nSUPABASE_DB_PASSWORD=dotenv-password\n", + ); + const { layer, out, linkedProjectCache, child, generator } = setup({ workdir }); + const configProvider = ConfigProvider.fromEnvRecord({}, { preserveEmptyStrings: true }); + + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.provideService(ConfigProvider.ConfigProvider, configProvider), + ); + + expect(out.stderrText).toContain("Connecting to 127.0.0.1 54321"); + expect(out.stdoutText).toContain("generated"); + expect(child.calls).toHaveLength(1); + expect(child.calls[0]).toMatchObject({ + command: "docker", + args: ["container", "inspect", "supabase_db_demo"], + extendEnv: true, + }); + // Env forwarding from the config's `.env` excludes SUPABASE_DB_PASSWORD. + expect(child.calls[0]?.env).toEqual({ + DOCKER_HOST: "project-daemon", + SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", + SUPABASE_USE_SLIM_IMAGES: "1", + }); + expect(generator.calls).toHaveLength(1); + const call = generator.calls[0]; + expect(call?.conn).toEqual({ + host: "127.0.0.1", + port: 54321, user: "postgres", password: "postgres", database: "postgres", - }), - ), - }); + runtimeParams: { statement_timeout: "15000" }, + connectTimeoutSeconds: 15, + }); + expect(call?.isLocal).toBe(true); + expect(call?.includedSchemas).toEqual(["public", "custom"]); + expect(call?.detectOneToOneRelationships).toBe(true); + expect(call?.conn.sslmode).toBeUndefined(); + expect(call?.conn.sslrootcertInline).toBeUndefined(); + expect(linkedProjectCache.cached).toBe(false); + }); + }, + ); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), - }), - ).pipe(Effect.provide(layer)); + it.live( + "falls back to podman when the docker executable is missing for local generation", + () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer, out, child, generator } = setup({ workdir, childDockerMissing: true }); - expect(generator.calls[0]?.conn.sslmode).toBeUndefined(); - expect(generator.calls[0]?.conn.sslrootcertInline).toBeUndefined(); - }); - }); + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - // --- Local generation: legacy backend (still inspects the Docker container) ------------- + expect(out.stdoutText).toContain("generated"); + expect(child.calls.map((call) => call.command)).toEqual(["docker", "podman"]); + expect(child.calls[1]?.args).toEqual(["container", "inspect", "supabase_db_demo"]); + expect(generator.calls).toHaveLength(1); + }); + }, + ); - it.live( - "generates locally via the legacy backend, connecting directly to the mapped port", - () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-")); + it.live("uses sanitized local docker ids and env-backed local db passwords", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-sanitized-")); writeConfig( workdir, [ - 'project_id = "demo"', + 'project_id = "..demo project with spaces"', "", "[api]", - 'schemas = ["public", "custom"]', + 'schemas = ["public"]', "", "[db]", "port = 54321", ].join("\n"), ); - writeFileSync( - join(workdir, "supabase", ".env"), - "DOCKER_HOST=project-daemon\nSUPABASE_INTERNAL_IMAGE_REGISTRY=docker.io\nSUPABASE_USE_SLIM_IMAGES=1\nSUPABASE_DB_PASSWORD=dotenv-password\n", - ); - const { layer, out, linkedProjectCache, child, generator } = setup({ workdir }); - const configProvider = ConfigProvider.fromEnvRecord({}, { preserveEmptyStrings: true }); + const { layer, child, generator } = setup({ workdir }); return Effect.gen(function* () { yield* genTypes(defaultFlags({ local: true })).pipe( Effect.provide(layer), - Effect.provideService(ConfigProvider.ConfigProvider, configProvider), + Effect.provideService( + ConfigProvider.ConfigProvider, + ConfigProvider.fromEnvRecord({ SUPABASE_DB_PASSWORD: "secret-password" }), + ), ); - expect(out.stderrText).toContain("Connecting to 127.0.0.1 54321"); - expect(out.stdoutText).toContain("generated"); - expect(child.calls).toHaveLength(1); - expect(child.calls[0]).toMatchObject({ - command: "docker", - args: ["container", "inspect", "supabase_db_demo"], - extendEnv: true, - }); - // Env forwarding from the config's `.env` excludes SUPABASE_DB_PASSWORD. - expect(child.calls[0]?.env).toEqual({ - DOCKER_HOST: "project-daemon", - SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", - SUPABASE_USE_SLIM_IMAGES: "1", - }); - expect(generator.calls).toHaveLength(1); - const call = generator.calls[0]; - expect(call?.conn).toEqual({ - host: "127.0.0.1", - port: 54321, - user: "postgres", - password: "postgres", - database: "postgres", - runtimeParams: { statement_timeout: "15000" }, - connectTimeoutSeconds: 15, - }); - expect(call?.isLocal).toBe(true); - expect(call?.includedSchemas).toEqual(["public", "custom"]); - expect(call?.detectOneToOneRelationships).toBe(true); - expect(call?.conn.sslmode).toBeUndefined(); - expect(call?.conn.sslrootcertInline).toBeUndefined(); - expect(linkedProjectCache.cached).toBe(false); + expect(child.calls[0]?.args).toEqual([ + "container", + "inspect", + "supabase_db_demo_project_with_spaces", + ]); + expect(generator.calls[0]?.conn.password).toBe("secret-password"); + expect(generator.calls[0]?.conn.host).toBe("127.0.0.1"); }); - }, - ); - - it.live("falls back to podman when the docker executable is missing for local generation", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( - "\n", - ), - ); - const { layer, out, child, generator } = setup({ workdir, childDockerMissing: true }); - - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - - expect(out.stdoutText).toContain("generated"); - expect(child.calls.map((call) => call.command)).toEqual(["docker", "podman"]); - expect(child.calls[1]?.args).toEqual(["container", "inspect", "supabase_db_demo"]); - expect(generator.calls).toHaveLength(1); }); - }); - - it.live("uses sanitized local docker ids and env-backed local db passwords", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-sanitized-")); - writeConfig( - workdir, - [ - 'project_id = "..demo project with spaces"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - "port = 54321", - ].join("\n"), - ); - const { layer, child, generator } = setup({ workdir }); - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.provideService( - ConfigProvider.ConfigProvider, - ConfigProvider.fromEnvRecord({ SUPABASE_DB_PASSWORD: "secret-password" }), - ), + it.live("forces v9 compat when rest-version reports v9 on a modern database", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-v9-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "major_version = 15", + "port = 54321", + ].join("\n"), ); + writeTempFile(workdir, "rest-version", "v9.0.1\n"); + const { layer, generator } = setup({ workdir }); - expect(child.calls[0]?.args).toEqual([ - "container", - "inspect", - "supabase_db_demo_project_with_spaces", - ]); - expect(generator.calls[0]?.conn.password).toBe("secret-password"); - expect(generator.calls[0]?.conn.host).toBe("127.0.0.1"); - }); - }); - - it.live("forces v9 compat when rest-version reports v9 on a modern database", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-v9-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - "major_version = 15", - "port = 54321", - ].join("\n"), - ); - writeTempFile(workdir, "rest-version", "v9.0.1\n"); - const { layer, generator } = setup({ workdir }); - - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - - expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); - }); - }); - - it.live("ignores rest-version v9 marker on databases older than 15", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-pg14-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - "major_version = 14", - "port = 54321", - ].join("\n"), - ); - writeTempFile(workdir, "rest-version", "v9.0.1\n"); - const { layer, generator } = setup({ workdir }); - - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.detectOneToOneRelationships).toBe(true); + expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); + }); }); - }); - - it.live("prefers explicit --schema over config schemas for local generation", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-schema-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public", "custom"]', - "", - "[db]", - "port = 54321", - ].join("\n"), - ); - const { layer, generator } = setup({ workdir }); - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).pipe( - Effect.provide(layer), + it.live("ignores rest-version v9 marker on databases older than 15", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-pg14-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "major_version = 14", + "port = 54321", + ].join("\n"), ); + writeTempFile(workdir, "rest-version", "v9.0.1\n"); + const { layer, generator } = setup({ workdir }); - expect(generator.calls[0]?.includedSchemas).toEqual(["auth", "storage"]); - }); - }); - - it.live("allows --swift-access-control for local non-Swift generation", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-swift-flag-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( - "\n", - ), - ); - const { layer, generator } = setup({ - workdir, - args: ["gen", "types", "--local", "--lang", "python", "--swift-access-control", "public"], - }); - - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), - ).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.lang).toBe("python"); - expect(generator.calls[0]?.swiftAccessControl).toBe("public"); + expect(generator.calls[0]?.detectOneToOneRelationships).toBe(true); + }); }); - }); - - it.live("falls back to the workdir basename when config has no project_id", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-noid-")); - writeConfig(workdir, ["[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join("\n")); - const { layer, child } = setup({ workdir }); - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + it.live("prefers explicit --schema over config schemas for local generation", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-schema-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public", "custom"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer, generator } = setup({ workdir }); - const inspectId = child.calls[0]?.args[2] ?? ""; - expect(inspectId.startsWith("supabase_db_")).toBe(true); - expect(inspectId).not.toBe("supabase_db_demo"); - }); - }); + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).pipe( + Effect.provide(layer), + ); - it.live("fails with not-running parity when the local db container is missing", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-missing-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( - "\n", - ), - ); - const { layer } = setup({ - workdir, - childExitCode: 1, - childStderr: ["Error: No such container: supabase_db_demo"], + expect(generator.calls[0]?.includedSchemas).toEqual(["auth", "storage"]); + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.exit, + it.live("allows --swift-access-control for local non-Swift generation", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-swift-flag-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), ); + const { layer, generator } = setup({ + workdir, + args: ["gen", "types", "--local", "--lang", "python", "--swift-access-control", "public"], + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("supabase start is not running."); - } - }); - }); - - it.live("keeps not-running parity when podman reports the local db container is missing", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-missing-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( - "\n", - ), - ); - const { layer, child } = setup({ - workdir, - childDockerMissing: true, - childExitCode: 1, - childStderr: ['Error: inspecting object: no such container "supabase_db_demo"'], + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), + ).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.lang).toBe("python"); + expect(generator.calls[0]?.swiftAccessControl).toBe("public"); + }); }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.exit, + it.live("falls back to the workdir basename when config has no project_id", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-noid-")); + writeConfig( + workdir, + ["[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join("\n"), ); + const { layer, child } = setup({ workdir }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("supabase start is not running."); - } - expect(child.calls.map((call) => call.command)).toEqual(["docker", "podman"]); + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + const inspectId = child.calls[0]?.args[2] ?? ""; + expect(inspectId.startsWith("supabase_db_")).toBe(true); + expect(inspectId).not.toBe("supabase_db_demo"); + }); }); - }); - it.live( - "preserves inspect failure details when local db inspection fails for other reasons", - () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-inspect-error-")); + it.live("fails with not-running parity when the local db container is missing", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-missing-")); writeConfig( workdir, [ @@ -2353,7 +2327,7 @@ describe("gen types", () => { const { layer } = setup({ workdir, childExitCode: 1, - childStderr: ["Cannot connect to the Docker daemon"], + childStderr: ["Error: No such container: supabase_db_demo"], }); return Effect.gen(function* () { @@ -2364,295 +2338,428 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "failed to inspect service: Cannot connect to the Docker daemon", - ); + expect(String(exit.cause)).toContain("supabase start is not running."); } }); - }, - ); - - it.live("generates locally with Go defaults when supabase/config.toml is missing", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-")); - const { layer, out, child, generator } = setup({ workdir, skipConfig: true }); + }); - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + it.live( + "keeps not-running parity when podman reports the local db container is missing", + () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-missing-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer, child } = setup({ + workdir, + childDockerMissing: true, + childExitCode: 1, + childStderr: ['Error: inspecting object: no such container "supabase_db_demo"'], + }); - const projectId = basename(workdir); - expect(child.calls[0]?.args).toEqual(["container", "inspect", localDbContainerId(projectId)]); - expect(generator.calls[0]?.conn).toEqual({ - host: "127.0.0.1", - port: 54322, - user: "postgres", - password: "postgres", - database: "postgres", - runtimeParams: { statement_timeout: "15000" }, - connectTimeoutSeconds: 15, - }); - expect(generator.calls[0]?.includedSchemas).toEqual(["public", "graphql_public"]); - expect(out.stdoutText).toContain("generated"); - }); - }); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); - it.live("honors local dotenv overrides when supabase/config.toml is missing", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-env-")); - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync( - join(supabaseDir, ".env"), - [ - "SUPABASE_PROJECT_ID=configless-env-project", - "SUPABASE_DB_PORT=55432", - "SUPABASE_API_SCHEMAS=private,graphql_public", - "SUPABASE_SERVICES_HOSTNAME=host.docker.internal", - "SUPABASE_INTERNAL_IMAGE_REGISTRY=mirror.example.com", - "", - ].join("\n"), + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("supabase start is not running."); + } + expect(child.calls.map((call) => call.command)).toEqual(["docker", "podman"]); + }); + }, ); - const { layer, out, child, generator } = setup({ workdir, skipConfig: true }); - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + it.live( + "preserves inspect failure details when local db inspection fails for other reasons", + () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-inspect-error-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer } = setup({ + workdir, + childExitCode: 1, + childStderr: ["Cannot connect to the Docker daemon"], + }); - expect(child.calls[0]?.args).toEqual([ - "container", - "inspect", - localDbContainerId("configless-env-project"), - ]); - expect(child.calls[0]?.env?.["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("mirror.example.com"); - expect(generator.calls[0]?.conn.host).toBe("host.docker.internal"); - expect(generator.calls[0]?.conn.port).toBe(55432); - expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private", "graphql_public"]); - expect(out.stdoutText).toContain("generated"); - }); - }); + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); - it.live("reports a generic inspect failure when docker emits no stderr", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-empty-stderr-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( - "\n", - ), + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "failed to inspect service: Cannot connect to the Docker daemon", + ); + } + }); + }, ); - const { layer } = setup({ workdir, childExitCode: 1 }); - - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("failed to inspect service"); - expect(String(exit.cause)).not.toContain("failed to inspect service:"); - } - }); - }); + it.live("generates locally with Go defaults when supabase/config.toml is missing", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-")); + const { layer, out, child, generator } = setup({ workdir, skipConfig: true }); - it.live("surfaces generation failures after local db inspection succeeds", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-run-error-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( - "\n", - ), - ); - const generator = mockGenTypesGenerator({ - generate: () => - Effect.fail( - new GenTypesGenerationError({ message: "failed to generate typescript types: boom" }), - ), + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + const projectId = basename(workdir); + expect(child.calls[0]?.args).toEqual([ + "container", + "inspect", + localDbContainerId(projectId), + ]); + expect(generator.calls[0]?.conn).toEqual({ + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + runtimeParams: { statement_timeout: "15000" }, + connectTimeoutSeconds: 15, + }); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "graphql_public"]); + expect(out.stdoutText).toContain("generated"); + }); }); - const { layer, child } = setup({ workdir, generator }); - return Effect.gen(function* () { - const exit = yield* genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.exit, + it.live("honors local dotenv overrides when supabase/config.toml is missing", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-env-")); + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync( + join(supabaseDir, ".env"), + [ + "SUPABASE_PROJECT_ID=configless-env-project", + "SUPABASE_DB_PORT=55432", + "SUPABASE_API_SCHEMAS=private,graphql_public", + "SUPABASE_SERVICES_HOSTNAME=host.docker.internal", + "SUPABASE_INTERNAL_IMAGE_REGISTRY=mirror.example.com", + "", + ].join("\n"), ); + const { layer, out, child, generator } = setup({ workdir, skipConfig: true }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("failed to generate typescript types: boom"); - } - expect(child.calls).toHaveLength(1); - expect(generator.calls).toHaveLength(1); + return Effect.gen(function* () { + yield* genTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + expect(child.calls[0]?.args).toEqual([ + "container", + "inspect", + localDbContainerId("configless-env-project"), + ]); + expect(child.calls[0]?.env?.["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe( + "mirror.example.com", + ); + expect(generator.calls[0]?.conn.host).toBe("host.docker.internal"); + expect(generator.calls[0]?.conn.port).toBe(55432); + expect(generator.calls[0]?.includedSchemas).toEqual([ + "public", + "private", + "graphql_public", + ]); + expect(out.stdoutText).toContain("generated"); + }); }); - }); - // --- Local generation: stack backend (no Docker inspection at all) ---------------------- + it.live("reports a generic inspect failure when docker emits no stderr", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-empty-stderr-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer } = setup({ workdir, childExitCode: 1 }); - it.live("resolves the stack local database directly, without inspecting any container", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-stack-local-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public", "custom"]', - "", - "[db]", - "port = 54321", - ].join("\n"), - ); - const { layer, out, child, dbConfig, generator } = setup({ - workdir, - dbConfigResolve: () => - Effect.succeed( - localResolvedConfig({ - host: "127.0.0.1", - port: 54321, - user: "postgres", - password: "postgres", - database: "postgres", - }), - ), + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("failed to inspect service"); + expect(String(exit.cause)).not.toContain("failed to inspect service:"); + } + }); }); - return Effect.gen(function* () { - yield* genTypes(defaultFlags({ local: true })).pipe( - Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"))), + it.live("surfaces generation failures after local db inspection succeeds", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-run-error-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), ); + const generator = mockGenTypesGenerator({ + generate: () => + Effect.fail( + new GenTypesGenerationError({ message: "failed to generate typescript types: boom" }), + ), + }); + const { layer, child } = setup({ workdir, generator }); - expect(out.stderrText).toContain("Connecting to 127.0.0.1 54321"); - expect(child.calls).toHaveLength(0); - expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("local"); - expect(generator.calls).toHaveLength(1); - const call = generator.calls[0]; - expect(call?.conn).toMatchObject({ - host: "127.0.0.1", - port: 54321, - user: "postgres", - password: "postgres", + return Effect.gen(function* () { + const exit = yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("failed to generate typescript types: boom"); + } + expect(child.calls).toHaveLength(1); + expect(generator.calls).toHaveLength(1); }); - expect(call?.isLocal).toBe(true); - expect(call?.includedSchemas).toEqual(["public", "custom"]); - // The stack backend never pins TLS for a local target either. - expect(call?.conn.sslmode).toBeUndefined(); - expect(call?.conn.sslrootcertInline).toBeUndefined(); }); }); - // --- db-url generation --------------------------------------------------------------- - - it.live( - "--db-url --schema succeeds on an explicit --workdir with no project of its own, since an explicit schema never needs the config load", - () => { - const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); + describe("Local generation: stack backend (no Docker inspection at all)", () => { + it.live("resolves the stack local database directly, without inspecting any container", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-stack-local-")); writeConfig( - root, - ['project_id = "demo"', "", "[api]", 'schemas = ["ancestor_only"]'].join("\n"), + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public", "custom"]', + "", + "[db]", + "port = 54321", + ].join("\n"), ); - const sub = join(root, "nested", "dir"); - mkdirSync(sub, { recursive: true }); - const { layer, dbConfig, generator } = setup({ - workdir: sub, - skipConfig: true, - explicitWorkdir: true, + const { layer, out, child, dbConfig, generator } = setup({ + workdir, + dbConfigResolve: () => + Effect.succeed( + localResolvedConfig({ + host: "127.0.0.1", + port: 54321, + user: "postgres", + password: "postgres", + database: "postgres", + }), + ), }); return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)); + yield* genTypes(defaultFlags({ local: true })).pipe( + Effect.provide(Layer.mergeAll(layer, stackBackendLayer("stack"))), + ); + expect(out.stderrText).toContain("Connecting to 127.0.0.1 54321"); + expect(child.calls).toHaveLength(0); expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("db-url"); - expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); + expect(dbConfig.resolves[0]?.connType).toBe("local"); + expect(generator.calls).toHaveLength(1); + const call = generator.calls[0]; + expect(call?.conn).toMatchObject({ + host: "127.0.0.1", + port: 54321, + user: "postgres", + password: "postgres", + }); + expect(call?.isLocal).toBe(true); + expect(call?.includedSchemas).toEqual(["public", "custom"]); + // The stack backend never pins TLS for a local target either. + expect(call?.conn.sslmode).toBeUndefined(); + expect(call?.conn.sslrootcertInline).toBeUndefined(); }); - }, - ); + }); + }); - it.live( - "resolves db-url generation through the DbConfigResolver, defaulting schemas from config", - () => { - const { layer, dbConfig, generator } = setup(); + describe("db-url generation", () => { + it.live( + "--db-url --schema succeeds on an explicit --workdir with no project of its own, since an explicit schema never needs the config load", + () => { + const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); + writeConfig( + root, + ['project_id = "demo"', "", "[api]", 'schemas = ["ancestor_only"]'].join("\n"), + ); + const sub = join(root, "nested", "dir"); + mkdirSync(sub, { recursive: true }); + const { layer, dbConfig, generator } = setup({ + workdir: sub, + skipConfig: true, + explicitWorkdir: true, + }); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + schema: ["public"], + }), + ).pipe(Effect.provide(layer)); + + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("db-url"); + expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); + }); + }, + ); + + it.live( + "resolves db-url generation through the DbConfigResolver, defaulting schemas from config", + () => { + const { layer, dbConfig, generator } = setup(); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + }), + ).pipe(Effect.provide(layer)); + + expect(dbConfig.resolves[0]).toEqual({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + connType: "db-url", + dnsResolver: "native", + }); + expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); + expect(generator.calls[0]?.isLocal).toBe(false); + expect(generator.calls[0]?.conn.runtimeParams?.["statement_timeout"]).toBe("15000"); + expect(generator.calls[0]?.conn.connectTimeoutSeconds).toBe(15); + }); + }, + ); + + it.live( + "keeps sub-second --query-timeout able to connect instead of disabling the timeout", + () => { + const { layer, generator } = setup(); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + queryTimeout: "400ms", + }), + ).pipe(Effect.provide(layer)); + + const call = generator.calls[0]; + expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("400"); + expect(call?.conn.connectTimeoutSeconds).toBeGreaterThanOrEqual(1); + }); + }, + ); + + it.live("leaves connectTimeoutSeconds unset for --query-timeout 0s", () => { + const { layer, generator } = setup(); return Effect.gen(function* () { yield* genTypes( defaultFlags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + queryTimeout: "0s", }), ).pipe(Effect.provide(layer)); - expect(dbConfig.resolves[0]).toEqual({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), - connType: "db-url", - dnsResolver: "native", - }); - expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); - expect(generator.calls[0]?.isLocal).toBe(false); - expect(generator.calls[0]?.conn.runtimeParams?.["statement_timeout"]).toBe("15000"); - expect(generator.calls[0]?.conn.connectTimeoutSeconds).toBe(15); + const call = generator.calls[0]; + expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("0"); + expect(call?.conn.connectTimeoutSeconds).toBeUndefined(); }); - }, - ); + }); - it.live( - "forwards --lang/--swift-access-control/--postgrest-v9-compat/--query-timeout for db-url generation", - () => { + it.live( + "forwards --lang/--swift-access-control/--postgrest-v9-compat/--query-timeout for db-url generation", + () => { + const { layer, generator } = setup(); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + lang: "swift", + schema: ["public"], + swiftAccessControl: "public", + postgrestV9Compat: true, + queryTimeout: "20s", + }), + ).pipe(Effect.provide(layer)); + + const call = generator.calls[0]; + expect(call?.lang).toBe("swift"); + expect(call?.swiftAccessControl).toBe("public"); + expect(call?.detectOneToOneRelationships).toBe(false); + expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); + expect(call?.conn.connectTimeoutSeconds).toBe(20); + }); + }, + ); + + it.live("allows --postgrest-v9-compat together with --db-url", () => { const { layer, generator } = setup(); return Effect.gen(function* () { yield* genTypes( defaultFlags({ dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), - lang: "swift", - schema: ["public"], - swiftAccessControl: "public", postgrestV9Compat: true, - queryTimeout: "20s", }), ).pipe(Effect.provide(layer)); - const call = generator.calls[0]; - expect(call?.lang).toBe("swift"); - expect(call?.swiftAccessControl).toBe("public"); - expect(call?.detectOneToOneRelationships).toBe(false); - expect(call?.conn.runtimeParams?.["statement_timeout"]).toBe("20000"); - expect(call?.conn.connectTimeoutSeconds).toBe(20); + expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); }); - }, - ); - - it.live("allows --postgrest-v9-compat together with --db-url", () => { - const { layer, generator } = setup(); - - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), - postgrestV9Compat: true, - }), - ).pipe(Effect.provide(layer)); - - expect(generator.calls[0]?.detectOneToOneRelationships).toBe(false); }); - }); - it.live("allows legacy positional non-typescript when --lang is explicitly set", () => { - const { layer, generator } = setup({ - args: ["gen", "types", "go", "--lang", "go"], - }); + it.live("allows legacy positional non-typescript when --lang is explicitly set", () => { + const { layer, generator } = setup({ + args: ["gen", "types", "go", "--lang", "go"], + }); - return Effect.gen(function* () { - yield* genTypes( - defaultFlags({ - dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), - lang: "go", - schema: ["public"], - }), - ).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + lang: "go", + schema: ["public"], + }), + ).pipe(Effect.provide(layer)); - expect(generator.calls[0]?.lang).toBe("go"); + expect(generator.calls[0]?.lang).toBe("go"); + }); }); }); }); diff --git a/apps/cli/src/commands/gen/types/types.oxfmt.ts b/apps/cli/src/commands/gen/types/types.oxfmt.ts index c98d0f34f9..fdde3057bf 100644 --- a/apps/cli/src/commands/gen/types/types.oxfmt.ts +++ b/apps/cli/src/commands/gen/types/types.oxfmt.ts @@ -50,6 +50,23 @@ function loadOxfmtBinding(loadCompiled: () => OxfmtBinding, specifier: string): } } +/** + * `process.report` omits `glibcVersionRuntime` on musl libc; the dev binary build + * (`scripts/build-binary.ts`) and source runs never set the `SUPABASE_LIBC` build define, so + * this is the only signal available to them. + */ +function isRunningOnMusl(): boolean { + try { + const report: unknown = process.report?.getReport(); + if (typeof report !== "object" || report === null || !("header" in report)) return false; + const header = report.header; + if (typeof header !== "object" || header === null) return false; + return !("glibcVersionRuntime" in header) || header.glibcVersionRuntime === undefined; + } catch { + return false; + } +} + function requireOxfmtBinding(): OxfmtBinding { if (process.platform === "darwin") { if (process.arch === "arm64") { @@ -67,8 +84,10 @@ function requireOxfmtBinding(): OxfmtBinding { } if (process.platform === "linux") { + const useMusl = + typeof SUPABASE_LIBC !== "undefined" ? SUPABASE_LIBC === "musl" : isRunningOnMusl(); if (process.arch === "arm64") { - if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") { + if (useMusl) { return loadOxfmtBinding( () => require("@oxfmt/binding-linux-arm64-musl"), "@oxfmt/binding-linux-arm64-musl", @@ -80,7 +99,7 @@ function requireOxfmtBinding(): OxfmtBinding { ); } if (process.arch === "x64") { - if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") { + if (useMusl) { return loadOxfmtBinding( () => require("@oxfmt/binding-linux-x64-musl"), "@oxfmt/binding-linux-x64-musl", diff --git a/apps/cli/src/commands/gen/types/types.shared.ts b/apps/cli/src/commands/gen/types/types.shared.ts index 1cffdfcb3c..eb84cf0ec5 100644 --- a/apps/cli/src/commands/gen/types/types.shared.ts +++ b/apps/cli/src/commands/gen/types/types.shared.ts @@ -28,7 +28,7 @@ export function defaultSchemas(extraSchemas: ReadonlyArray = []) { return [...new Set(["public", ...extraSchemas])]; } -export function parseQueryTimeoutSeconds( +export function parseQueryTimeoutMillis( raw: string, ): Effect.Effect { return Effect.gen(function* () { @@ -75,7 +75,7 @@ export function parseQueryTimeoutSeconds( ); } - return Math.round(totalMillis / 1_000); + return totalMillis; }); } diff --git a/apps/cli/src/commands/gen/types/types.unit.test.ts b/apps/cli/src/commands/gen/types/types.unit.test.ts index ea16065e64..deaade4db4 100644 --- a/apps/cli/src/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.unit.test.ts @@ -10,7 +10,7 @@ import { localDbContainerId, localDbPassword, localNetworkId, - parseQueryTimeoutSeconds, + parseQueryTimeoutMillis, } from "./types.shared.ts"; const resolvePassword = () => @@ -41,54 +41,54 @@ function withEnv(key: string, value: string | undefined, run: () => T): T { } } -describe("parseQueryTimeoutSeconds", () => { +describe("parseQueryTimeoutMillis", () => { it.effect("parses compound Go durations", () => Effect.gen(function* () { - expect(yield* parseQueryTimeoutSeconds("15s")).toBe(15); - expect(yield* parseQueryTimeoutSeconds("1h")).toBe(3600); - expect(yield* parseQueryTimeoutSeconds("1m30s")).toBe(90); - expect(yield* parseQueryTimeoutSeconds("2h30m")).toBe(9000); + expect(yield* parseQueryTimeoutMillis("15s")).toBe(15000); + expect(yield* parseQueryTimeoutMillis("1h")).toBe(3600000); + expect(yield* parseQueryTimeoutMillis("1m30s")).toBe(90000); + expect(yield* parseQueryTimeoutMillis("2h30m")).toBe(9000000); }), ); - it.effect("rounds sub-second durations to whole seconds", () => + it.effect("preserves sub-second precision", () => Effect.gen(function* () { - expect(yield* parseQueryTimeoutSeconds("500ms")).toBe(1); - expect(yield* parseQueryTimeoutSeconds("400ms")).toBe(0); + expect(yield* parseQueryTimeoutMillis("500ms")).toBe(500); + expect(yield* parseQueryTimeoutMillis("400ms")).toBe(400); }), ); it.effect("rejects an empty duration", () => Effect.gen(function* () { - const exit = yield* parseQueryTimeoutSeconds(" ").pipe(Effect.exit); + const exit = yield* parseQueryTimeoutMillis(" ").pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); }), ); it.effect("rejects a duration with a leading non-duration prefix", () => Effect.gen(function* () { - const exit = yield* parseQueryTimeoutSeconds("x15s").pipe(Effect.exit); + const exit = yield* parseQueryTimeoutMillis("x15s").pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); }), ); it.effect("rejects a duration with trailing junk", () => Effect.gen(function* () { - const exit = yield* parseQueryTimeoutSeconds("15s30").pipe(Effect.exit); + const exit = yield* parseQueryTimeoutMillis("15s30").pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); }), ); it.effect("rejects a string with no recognizable units", () => Effect.gen(function* () { - const exit = yield* parseQueryTimeoutSeconds("abc").pipe(Effect.exit); + const exit = yield* parseQueryTimeoutMillis("abc").pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); }), ); it.effect("rejects a negative duration", () => Effect.gen(function* () { - const exit = yield* parseQueryTimeoutSeconds("-5s").pipe(Effect.exit); + const exit = yield* parseQueryTimeoutMillis("-5s").pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); }), ); diff --git a/apps/cli/tsconfig.types.json b/apps/cli/tsconfig.types.json index fa1dfeebf8..ac42ee7bfb 100644 --- a/apps/cli/tsconfig.types.json +++ b/apps/cli/tsconfig.types.json @@ -3,7 +3,7 @@ // its unbuilt `src/*.ts`, which does not satisfy this workspace's stricter compiler options; // its published `dist/*.d.ts` describes the same API and does. The pin cannot live in // `tsconfig.json` because Bun honours `paths` at runtime too, and would then load a - // declaration file instead of the implementation. `types.generator.integration.test.ts` + // declaration file instead of the implementation. `types.generator.unit.test.ts` // exercises the Bun-resolved runtime API so the two views cannot drift unnoticed. "extends": "./tsconfig.json", "compilerOptions": { From cee59507cca88c94978a3e89761fed39ee415298 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 17 Sep 2026 10:05:56 +0100 Subject: [PATCH 3/4] fix(cli): restore the gen types pooler fallback and share build externals (CLI-2366) Routing generation through `DbConnection` lost the IPv4 pooler retry on IPv4-only networks. `toConnectError` builds `DbConnectError` from a rendered message and drops the driver error, so the structured errno classifier could no longer see `ENOTFOUND`, or `EHOSTUNREACH`/`EADDRNOTAVAIL` against an IPv6 address, and the direct-host failure never fell back to the pooler. The boundary now classifies the failure while the driver error is still in scope and carries the verdict on `DbConnectError.ipv6Unreachable`, the way `retryable` already works, rather than attaching a raw driver error to a domain error. The fallback tests are built from errors the connection layer actually produces instead of hand-shaped ones. `tools/release/local-release.ts` compiles the CLI through its own `bun build` invocation, which did not mark oxfmt's optional prettier imports external and so could not compile a local release. It now shares the same externals list as the other two build paths. Co-Authored-By: Claude Opus 5 --- apps/cli/scripts/bundle-externals.ts | 9 ++ .../command-internal/db-connection.errors.ts | 5 + .../db-connection.sql-pg.layer.ts | 8 +- .../db-connection.sql-pg.unit.test.ts | 27 +++++ .../src/commands/gen/types/types.handler.ts | 17 +++- .../gen/types/types.integration.test.ts | 98 +++++++++++++++++-- tools/release/local-release.ts | 3 +- 7 files changed, 153 insertions(+), 14 deletions(-) diff --git a/apps/cli/scripts/bundle-externals.ts b/apps/cli/scripts/bundle-externals.ts index 5cee5d7355..25d7760b11 100644 --- a/apps/cli/scripts/bundle-externals.ts +++ b/apps/cli/scripts/bundle-externals.ts @@ -13,3 +13,12 @@ export const OXFMT_OPTIONAL_PLUGIN_EXTERNALS = [ "prettier-plugin-astro", "prettier-plugin-marko", ] as const; + +/** + * {@link OXFMT_OPTIONAL_PLUGIN_EXTERNALS} as `--external=` CLI arguments, for `bun build` + * invocations that shell out (e.g. `tools/release/local-release.ts`) rather than calling the + * `Bun.build()` object API. + */ +export const oxfmtExternalArgs = OXFMT_OPTIONAL_PLUGIN_EXTERNALS.map( + (name) => `--external=${name}`, +); diff --git a/apps/cli/src/command-internal/db-connection.errors.ts b/apps/cli/src/command-internal/db-connection.errors.ts index 49f8a507d1..fc4391d2ac 100644 --- a/apps/cli/src/command-internal/db-connection.errors.ts +++ b/apps/cli/src/command-internal/db-connection.errors.ts @@ -17,6 +17,11 @@ export class DbConnectError extends Data.TaggedError("DbConnectError")<{ * fresh-db bootstrap's connect retry keys off this field. */ readonly retryable?: boolean; + /** + * True when the failure is an IPv6 dial failure that an IPv4 pooler retry can recover; + * `gen types`' pooler fallback keys off this field. + */ + readonly ipv6Unreachable?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.dbConnection; diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index 74562a5519..fda281d752 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -15,6 +15,7 @@ import { connectFailureMessage, connectSuggestion, isDialFailure, + isIPv6ConnectivityErrorCause, isSqlState, } from "./connect-errors.ts"; import { DbConnectError, DbCopyError, DbExecError } from "./db-connection.errors.ts"; @@ -610,7 +611,11 @@ export const acquireProbedPool =

( }); /** Maps a driver connect failure to a credential-free `DbConnectError`. */ -const toConnectError = (cfg: PgConnInput, isLocal: boolean, error: unknown): DbConnectError => { +export const toConnectError = ( + cfg: PgConnInput, + isLocal: boolean, + error: unknown, +): DbConnectError => { const suggestion = cfg.suggestionContext === undefined ? undefined @@ -619,6 +624,7 @@ const toConnectError = (cfg: PgConnInput, isLocal: boolean, error: unknown): DbC message: `failed to connect to postgres: ${connectFailureMessage(cfg, error)}`, ...(suggestion === undefined ? {} : { suggestion }), ...(isDialFailure(error) ? { retryable: true } : {}), + ...(isIPv6ConnectivityErrorCause(error) ? { ipv6Unreachable: true } : {}), }); }; diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts index 424aa944b9..ef21e00ae7 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts @@ -21,8 +21,10 @@ import { mergedConnectionOptions, sslConfigsFor, sslOptionFor, + toConnectError, toExecError, } from "./db-connection.sql-pg.layer.ts"; +import type { PgConnInput } from "./db-connection.service.ts"; describe("buildConnectionUrl", () => { const base = { @@ -548,6 +550,31 @@ describe("toExecError (pg server-error extraction)", () => { }); }); +describe("toConnectError (ipv6Unreachable classification)", () => { + const cfg: PgConnInput = { + host: "db.project-ref.supabase.co", + port: 5432, + user: "postgres", + password: "pw", + database: "postgres", + }; + + it.each([ + ["ENOTFOUND", { code: "ENOTFOUND" }], + ["EHOSTUNREACH with an IPv6 address", { code: "EHOSTUNREACH", address: "2600:1f18::1" }], + ["EADDRNOTAVAIL with an IPv6 address", { code: "EADDRNOTAVAIL", address: "2600:1f18::1" }], + ["ENETUNREACH with an IPv6 address", { code: "ENETUNREACH", address: "2600:1f18::1" }], + ])("sets ipv6Unreachable for a %s driver error", (_description, driverError) => { + const error = toConnectError(cfg, false, driverError); + expect(error.ipv6Unreachable).toBe(true); + }); + + it("does not set ipv6Unreachable for a refused connection", () => { + const error = toConnectError(cfg, false, { code: "ECONNREFUSED" }); + expect(error.ipv6Unreachable).toBeUndefined(); + }); +}); + describe("PgBatchQuery.submit", () => { const fakeConnection = (writable: boolean, opts: { dieOnUncork?: boolean } = {}) => { const frames: Array = []; diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 2c11723e33..35aed587c2 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -1,7 +1,7 @@ import type { LoadedCliConfig } from "@supabase/config/effect"; import { loadCliConfig } from "@supabase/config/internal"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; +import { Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } from "effect"; import { getDomain } from "tldts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -25,6 +25,7 @@ import type { DbConfigFlags } from "../../../command-internal/db-config.types.ts import { poolerConfigFromConnectionString } from "../../../command-internal/db-config.parse.ts"; import { readDbToml } from "../../../command-internal/db-config.toml-read.ts"; import { getHostname } from "../../../command-internal/hostname.ts"; +import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { tempPaths } from "../../../command-internal/temp-paths.ts"; import { @@ -48,7 +49,7 @@ import { GenTypesUnexpectedStatusError, GenTypesWorkdirError, } from "./types.errors.ts"; -import { GenTypesGenerator } from "./types.generator.service.ts"; +import { type GenTypesGenerationError, GenTypesGenerator } from "./types.generator.service.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { @@ -302,6 +303,16 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) : { connectTimeoutSeconds: Math.max(1, Math.ceil(queryTimeoutMillis / 1000)) }), }); + /** + * `toConnectError` classifies the IPv6-unreachable dial failure at the connection boundary and + * exposes it via `DbConnectError.ipv6Unreachable`, so a `DbConnectError` no longer carries the + * raw driver cause `isIPv6ConnectivityErrorCause` needs; fall back to it for any other error. + */ + const classifyGenerateError = (error: DbConnectError | GenTypesGenerationError): boolean => + Predicate.isTagged(error, "DbConnectError") + ? (error.ipv6Unreachable ?? false) + : isIPv6ConnectivityErrorCause(error); + const runGenerate = (input: { readonly conn: PgConnInput; readonly isLocal: boolean; @@ -340,7 +351,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) directHost: input.poolerFallback.directHost, eligible: input.poolerFallback.eligible, resolveFallback: input.poolerFallback.resolve, - classifyError: isIPv6ConnectivityErrorCause, + classifyError: classifyGenerateError, }); yield* output.raw(types); diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 84ad23e2bd..d83b21f0f3 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -38,6 +38,8 @@ import { mockTelemetryStateTracked, } from "../../../../tests/helpers/command-mocks.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; +import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; +import { toConnectError } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import type { DbConfigError } from "../../../command-internal/db-config.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConfigLoadError } from "../../../command-internal/db-config.errors.ts"; @@ -152,7 +154,7 @@ function mockGenTypesGenerator( readonly generate?: ( input: GenTypesGenerateInput, callIndex: number, - ) => Effect.Effect; + ) => Effect.Effect; readonly output?: string; } = {}, ) { @@ -176,7 +178,7 @@ function mockGenTypesGenerator( /** One `GenTypesGenerator.generate` outcome per attempt — models a failing then a retried call. */ function sequentialGenerator( - steps: ReadonlyArray<() => Effect.Effect>, + steps: ReadonlyArray<() => Effect.Effect>, ) { return mockGenTypesGenerator({ generate: (_input, index) => @@ -184,10 +186,44 @@ function sequentialGenerator( }); } -function ipv6Failure(lang = "go") { - return new GenTypesGenerationError({ - message: `failed to generate ${lang} types: could not translate host name to address: No address associated with hostname`, - }); +const DIAL_FAILURE_CONN: PgConnInput = { + host: "db.example.supabase.co", + port: 5432, + user: "postgres", + password: "pw", + database: "postgres", +}; + +/** + * A `DbConnectError` shaped exactly as `toConnectError` builds one from a real ENETUNREACH dial + * failure against an IPv6 literal — the connection layer no longer forwards the raw driver cause, + * so the pooler-fallback classifier must key off `DbConnectError.ipv6Unreachable` instead. + */ +function ipv6Failure(): DbConnectError { + return toConnectError( + DIAL_FAILURE_CONN, + false, + Object.assign(new Error("connect ENETUNREACH 2600:1f18::1:5432"), { + code: "ENETUNREACH", + address: "2600:1f18::1", + port: 5432, + }), + ); +} + +/** + * A `DbConnectError` for an ENOTFOUND (DNS miss) dial failure — carries no IPv6 literal in its + * rendered message, so only the structured `code` classification `toConnectError` performs at the + * connection boundary (not a message-text fallback) can mark it IPv6-pooler-retryable. + */ +function enotfoundFailure(): DbConnectError { + return toConnectError( + DIAL_FAILURE_CONN, + false, + Object.assign(new Error("getaddrinfo ENOTFOUND db.example.supabase.co"), { + code: "ENOTFOUND", + }), + ); } function nonIpv6Failure(lang = "go") { @@ -1658,6 +1694,50 @@ describe("gen types", () => { }); }); + it.live("retries through the IPv4 pooler on an ENOTFOUND direct-host dial failure", () => { + const poolerConn: PgConnInput = { + host: "127.0.0.1", + port: 5432, + user: `postgres.${VALID_REF}`, + password: "pooler-password", + database: "postgres", + }; + const generator = sequentialGenerator([ + () => Effect.fail(enotfoundFailure()), + () => Effect.succeed("type RetriedViaPooler struct {}"), + ]); + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", VALID_REF], + generator, + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some(poolerConn), + }); + + return Effect.gen(function* () { + yield* genTypes( + defaultFlags({ + projectId: Option.some(VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); + expect(generator.calls).toHaveLength(2); + expect(generator.calls[1]?.conn.host).toBe("127.0.0.1"); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }); + }); + it.live("does not retry through the pooler when the failure is not IPv6-classified", () => { const generator = sequentialGenerator([() => Effect.fail(nonIpv6Failure())]); const { layer, dbConfig } = setup({ @@ -1798,7 +1878,7 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("No address associated with hostname"); + expect(String(exit.cause)).toContain("dial error (connect ENETUNREACH"); expect(String(exit.cause)).not.toContain("pooler fallback failed"); } expect(generator.calls).toHaveLength(1); @@ -1808,7 +1888,7 @@ describe("gen types", () => { it.live("retries preview branch generation through the branch IPv4 pooler", () => { const poolerHost = "aws-0-us-east-1.pooler.supabase.com"; const generator = sequentialGenerator([ - () => Effect.fail(ipv6Failure("python")), + () => Effect.fail(ipv6Failure()), () => Effect.succeed("class RetriedViaBranchPooler(BaseModel):"), ]); const { layer, api } = setup({ @@ -1866,7 +1946,7 @@ describe("gen types", () => { }); it.live("skips preview branch pooler fallback when the pooler URL fails validation", () => { - const generator = sequentialGenerator([() => Effect.fail(ipv6Failure("python"))]); + const generator = sequentialGenerator([() => Effect.fail(ipv6Failure())]); const { layer, api } = setup({ args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], generator, diff --git a/tools/release/local-release.ts b/tools/release/local-release.ts index 25e1267cc8..c2ae5891cf 100644 --- a/tools/release/local-release.ts +++ b/tools/release/local-release.ts @@ -16,6 +16,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { oxfmtExternalArgs } from "../../apps/cli/scripts/bundle-externals.ts"; const PORT = 4873; const REGISTRY = `http://localhost:${PORT}`; @@ -190,7 +191,7 @@ async function main() { const libc = libcForBunTarget(platform.bunTarget); console.log("[1/3] Compiling CLI binary..."); - await $`bun build ${entrypoint} --compile --target=${platform.bunTarget} --define=SUPABASE_LIBC=${JSON.stringify(libc)} --outfile=${bunBinary}`; + await $`bun build ${entrypoint} --compile --target=${platform.bunTarget} --define=SUPABASE_LIBC=${JSON.stringify(libc)} --outfile=${bunBinary} ${oxfmtExternalArgs}`; { const goBinary = path.join(tmpPlatformBinDir, `supabase-go${platform.ext}`); From 967878c8548ed5c0fbabdde6683af5db01506509 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 17 Sep 2026 11:13:18 +0100 Subject: [PATCH 4/4] fix(cli): honor an explicit db-url sslmode against a local target (CLI-2366) `sslConfigsFor` exempts a local target from TLS before it reads `sslmode`, so a DSN pointing at a TLS tunnel on loopback with `sslmode=require` or `verify-full` was silently downgraded to plaintext, and the root cert was never loaded because the CA derivation is gated the same way. `gen types --db-url` is documented as honoring the DSN's own TLS settings, and an explicitly requested verification must not be dropped. The acquisition path now treats a connection that names `sslmode`, `sslrootcert` or an inline CA as TLS-exempt only when it is also not local: `sslConfigsFor`'s own contract is unchanged, it just receives the caller's exemption decision rather than the raw classification. A loopback DSN that asks for nothing still connects in plaintext, and only DSN parsing ever populates these fields, so ordinary `--local` connections are unaffected. Co-Authored-By: Claude Opus 5 --- .../db-connection.sql-pg.integration.test.ts | 136 ++++++++++++++++++ .../db-connection.sql-pg.layer.ts | 43 +++++- .../db-connection.sql-pg.unit.test.ts | 33 +++++ .../src/commands/gen/types/SIDE_EFFECTS.md | 6 +- 4 files changed, 210 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts index 9ddec2051d..2d98b4a564 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts @@ -129,6 +129,61 @@ const BIND_COMPLETE = wireMessage("2", Buffer.alloc(0)); const NO_DATA = wireMessage("n", Buffer.alloc(0)); const EMPTY_QUERY = wireMessage("I", Buffer.alloc(0)); +/** + * A fake Postgres server that completes an auth-less startup handshake, answers every + * simple-protocol query with `SELECT 1`'s result (satisfying `acquireProbedPool`'s own probe), + * and records whether the client sent an SSLRequest first — so a test can prove whether TLS was + * attempted independent of how the attempt is resolved. + */ +const fakeStartupServer = (): Promise<{ + readonly port: number; + readonly close: () => void; + readonly sawSslRequest: () => boolean; +}> => + new Promise((resolve) => { + let sawSslRequest = false; + const server = net.createServer((socket) => { + let sawStartup = false; + let pending = Buffer.alloc(0); + socket.on("data", (data: Buffer) => { + pending = Buffer.concat([pending, data]); + for (;;) { + if (!sawStartup) { + if (pending.length < 8) return; + const length = pending.readInt32BE(0); + if (pending.length < length) return; + if (pending.readInt32BE(4) === 80877103) { + sawSslRequest = true; + socket.write("N"); + } else { + sawStartup = true; + socket.write(Buffer.concat([AUTHENTICATION_OK, READY_FOR_QUERY])); + } + pending = pending.subarray(length); + continue; + } + if (pending.length < 5) return; + const length = pending.readInt32BE(1); + if (pending.length < length + 1) return; + const type = String.fromCharCode(pending[0] ?? 0); + pending = pending.subarray(length + 1); + if (type === "Q") { + socket.write(Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY])); + } + } + }); + socket.on("error", () => {}); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as net.AddressInfo; + resolve({ + port: address.port, + close: () => server.close(), + sawSslRequest: () => sawSslRequest, + }); + }); + }); + const readCString = (body: Buffer, offset: number): readonly [string, number] => { const end = body.indexOf(0, offset); return [body.toString("utf8", offset, end), end + 1]; @@ -793,3 +848,84 @@ describe("acquirePgPool", () => { }), ); }); + +describe("a local target's explicit TLS request (CLI-2366: honor --db-url's own sslmode/sslrootcert)", () => { + it.live("attempts TLS instead of forcing plaintext when a local target's DSN sets sslmode", () => + Effect.gen(function* () { + const server = yield* Effect.promise(fakeStartupServer); + const error = yield* connectFailure({ port: server.port, sslmode: "require" }).pipe( + Effect.ensuring(Effect.sync(server.close)), + ); + expect(server.sawSslRequest()).toBe(true); + expect(error.message).toContain("tls error (The server does not support SSL connections)"); + expect(error.suggestion).toBe( + "This server does not accept TLS. Set `sslmode=disable` on the connection string to connect in plaintext.", + ); + }), + ); + + it.live("stays plaintext for a local target when sslmode=disable is set explicitly", () => + Effect.gen(function* () { + const server = yield* Effect.promise(fakeStartupServer); + yield* Effect.gen(function* () { + const pool = yield* acquirePgPool( + { + host: "127.0.0.1", + port: server.port, + user: "postgres", + password: "postgres", + database: "postgres", + sslmode: "disable", + }, + { isLocal: true, dnsResolver: "native" }, + ); + yield* Effect.tryPromise(() => pool.query("select 1")); + }).pipe(Effect.scoped, Effect.ensuring(Effect.sync(server.close))); + expect(server.sawSslRequest()).toBe(false); + }), + ); + + it.live( + "stays plaintext for a local target with no sslmode/sslrootcert set (the default must not regress)", + () => + Effect.gen(function* () { + const server = yield* Effect.promise(fakeStartupServer); + yield* Effect.gen(function* () { + const pool = yield* acquirePgPool( + { + host: "127.0.0.1", + port: server.port, + user: "postgres", + password: "postgres", + database: "postgres", + }, + { isLocal: true, dnsResolver: "native" }, + ); + yield* Effect.tryPromise(() => pool.query("select 1")); + }).pipe(Effect.scoped, Effect.ensuring(Effect.sync(server.close))); + expect(server.sawSslRequest()).toBe(false); + }), + ); + + it.live( + "loads sslrootcert for a local target when the DSN explicitly set it, instead of silently ignoring it", + () => + Effect.gen(function* () { + const missingPath = "/tmp/cli-2366-missing-sslrootcert.pem"; + const error = yield* connectFailure({ + port: 54322, + sslrootcert: missingPath, + sslmode: "verify-full", + }); + expect(error.message).toContain(`failed to read sslrootcert ${missingPath}`); + }), + ); + + it.live("keeps a remote target's sslrootcert loading unchanged", () => + Effect.gen(function* () { + const missingPath = "/tmp/cli-2366-missing-sslrootcert-remote.pem"; + const error = yield* connectFailure({ port: 5432, sslrootcert: missingPath }, false); + expect(error.message).toContain(`failed to read sslrootcert ${missingPath}`); + }), + ); +}); diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index fda281d752..7fdf023767 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -396,6 +396,19 @@ export interface ClientCert { readonly passphrase?: string; } +/** + * Whether the DSN itself asked for TLS behavior: `--db-url`'s `sslmode`/`sslrootcert` are honored + * even against a target classified local (e.g. a TLS tunnel on the loopback stack), so `isLocal` + * alone must not force plaintext when one of these is set. + */ +export function tlsExplicitlyRequested(cfg: PgConnInput): boolean { + return ( + cfg.sslmode !== undefined || + (cfg.sslrootcert?.length ?? 0) > 0 || + (cfg.sslrootcertInline?.length ?? 0) > 0 + ); +} + export function sslOptionFor( sslmode: string | undefined, isLocal: boolean, @@ -443,6 +456,9 @@ export function sslOptionFor( * so a failed handshake on the default `prefer` mode fails loudly rather than silently * downgrading to plaintext. `servername` targets the original hostname per dial host when a * DoH-resolved IP was substituted; `caCert` promotes `require` to `verify-ca` when set. + * `isLocal` is the caller's TLS-exemption decision, not the raw target classification: a local + * target that explicitly set `sslmode`/`sslrootcert` (see {@link tlsExplicitlyRequested}) is not + * exempt, so the caller passes `false` for it in that case. */ export function sslConfigsFor( sslmode: string | undefined, @@ -636,6 +652,9 @@ export const toConnectError = ( */ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbConnectOptions) => Effect.gen(function* () { + // A local target that explicitly set `sslmode`/`sslrootcert` (e.g. a TLS tunnel on the + // loopback stack) is not exempt from TLS; only the default loopback case stays plaintext. + const explicitTls = tlsExplicitlyRequested(cfg); // Dials the primary host then each HA fallback from `cfg.fallbacks`, in order. When // `--dns-resolver https` is set, each host resolves to all its Cloudflare DoH IPs up front // and each is retried in turn; the original hostname is kept as the TLS `servername` so @@ -688,15 +707,20 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC // `failed to connect to postgres:` prefix plus the connection identity and underlying driver // cause, not the bare `SqlError` toString, which drops that detail. // Loads the `sslrootcert` CA bundle; a missing/unreadable file aborts. Skipped for local - // connections. Loaded whenever any dial target is non-socket, since a socket primary can - // still have a TCP fallback that needs it ({@link sslConfigsFor} already plaintexts socket - // targets). + // connections, unless the local target explicitly requested TLS. Loaded whenever any dial + // target is non-socket, since a socket primary can still have a TCP fallback that needs it + // ({@link sslConfigsFor} already plaintexts socket targets). const rootcertPath = cfg.sslrootcert; const anyTcpTarget = dialTargets.some(({ dialHost }) => !isUnixSocketHost(dialHost)); const caCert = - cfg.sslrootcertInline !== undefined && cfg.sslrootcertInline.length > 0 && !isLocal + cfg.sslrootcertInline !== undefined && + cfg.sslrootcertInline.length > 0 && + (!isLocal || explicitTls) ? cfg.sslrootcertInline - : rootcertPath !== undefined && rootcertPath.length > 0 && !isLocal && anyTcpTarget + : rootcertPath !== undefined && + rootcertPath.length > 0 && + (!isLocal || explicitTls) && + anyTcpTarget ? yield* Effect.try({ try: () => readFileSync(rootcertPath, "utf8"), catch: (error) => @@ -736,7 +760,14 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC // each dial target (host × resolved IPs), with `servername` per target set to the original // hostname when dialing a DoH-resolved IP. const attempts = dialTargets.flatMap(({ dialHost, port, servername }) => - sslConfigsFor(cfg.sslmode, isLocal, servername, caCert, dialHost, clientCert).map((ssl) => ({ + sslConfigsFor( + cfg.sslmode, + isLocal && !explicitTls, + servername, + caCert, + dialHost, + clientCert, + ).map((ssl) => ({ pool: makePool(dialHost, port, ssl), // The fallback chain only short-circuits on an auth error when the failed attempt used // TLS; a TLS config is any non-plaintext `ssl` value. diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts index ef21e00ae7..039c6338cf 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts @@ -21,6 +21,7 @@ import { mergedConnectionOptions, sslConfigsFor, sslOptionFor, + tlsExplicitlyRequested, toConnectError, toExecError, } from "./db-connection.sql-pg.layer.ts"; @@ -259,6 +260,38 @@ describe("sslConfigsFor (pgconn fallback list)", () => { }); }); +describe("tlsExplicitlyRequested (CLI-2366: --db-url TLS against a local target)", () => { + const base: PgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }; + + it("is false when the DSN set neither sslmode nor a root cert", () => { + expect(tlsExplicitlyRequested(base)).toBe(false); + }); + + it("is true for any sslmode, including disable (still resolved by sslConfigsFor)", () => { + expect(tlsExplicitlyRequested({ ...base, sslmode: "require" })).toBe(true); + expect(tlsExplicitlyRequested({ ...base, sslmode: "verify-full" })).toBe(true); + expect(tlsExplicitlyRequested({ ...base, sslmode: "disable" })).toBe(true); + }); + + it("is true when a root cert (file path or inline PEM) is set", () => { + expect(tlsExplicitlyRequested({ ...base, sslrootcert: "/tmp/ca.pem" })).toBe(true); + expect( + tlsExplicitlyRequested({ ...base, sslrootcertInline: "-----BEGIN CERTIFICATE-----" }), + ).toBe(true); + }); + + it("ignores an empty sslrootcert/sslrootcertInline string", () => { + expect(tlsExplicitlyRequested({ ...base, sslrootcert: "" })).toBe(false); + expect(tlsExplicitlyRequested({ ...base, sslrootcertInline: "" })).toBe(false); + }); +}); + describe("buildRawPgConfig", () => { const base = { user: "postgres", password: "pw", port: 5432, database: "postgres", host: "h" }; diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 97f5bdfcdb..a7b4c24edc 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -124,9 +124,11 @@ Not applicable. - `--linked` / `--project-id` / the implicit linked fallback / a resolved preview branch connect with `sslmode=require` and the bundled Supabase CA pinned (promoted to `verify-ca`), matching prior behavior. - - `--db-url` honors the DSN's own `sslmode`/`sslrootcert` when either is set; + - `--db-url` honors the DSN's own `sslmode`/`sslrootcert` when either is set, even + against a target classified local (e.g. a TLS tunnel on the loopback stack); otherwise, a known Supabase host gets the Supabase CA pinned the same way, and any - other host uses the connection resolver's default. + other host — including a loopback target where the DSN sets neither — uses the + connection resolver's default, which is plaintext for a local target. - `--local` uses no TLS. - **Sanctioned intentional divergence (CLI-1988 parity ruling):** `--lang` accepts `typescript` (default), `go`, `swift`, or `python`. Project-ref paths