diff --git a/apps/cli/package.json b/apps/cli/package.json index e70a02e3c9..3aaefb0fba 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -41,7 +41,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", @@ -57,6 +57,14 @@ "@effect/vitest": "catalog:", "@modelcontextprotocol/sdk": "^1.30.0", "@napi-rs/keyring": "^1.3.0", + "@oxfmt/binding-darwin-arm64": "0.65.0", + "@oxfmt/binding-darwin-x64": "0.65.0", + "@oxfmt/binding-linux-arm64-gnu": "0.65.0", + "@oxfmt/binding-linux-arm64-musl": "0.65.0", + "@oxfmt/binding-linux-x64-gnu": "0.65.0", + "@oxfmt/binding-linux-x64-musl": "0.65.0", + "@oxfmt/binding-win32-arm64-msvc": "0.65.0", + "@oxfmt/binding-win32-x64-msvc": "0.65.0", "@parcel/watcher": "^2.6.0", "@parcel/watcher-darwin-arm64": "2.6.0", "@parcel/watcher-darwin-x64": "2.6.0", @@ -68,8 +76,9 @@ "@parcel/watcher-win32-x64": "2.6.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", - "@supabase/pg-delta": "1.0.0-alpha.47", + "@supabase/pg-delta": "1.0.0-alpha.48", "@supabase/pg-topo": "1.0.0-alpha.6", + "@supabase/postgrest-typegen": "0.2.1", "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 024ee6b882..e8835b2042 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -2,6 +2,7 @@ import { $ } from "bun"; import process from "node:process"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; +import { oxfmtExternalArgs } from "./bundle-externals.ts"; /** * Compile a single CLI shell to a standalone binary, embedding the pre-bundled @@ -30,4 +31,4 @@ const defineArg = `--define=SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE=${JSON.string await bundleServeMainTemplate(), )}`; -await $`bun build ${entrypoint} --compile ${versionDefine} ${defineArg} --outfile ${outfile}`; +await $`bun build ${entrypoint} --compile ${versionDefine} ${defineArg} ${oxfmtExternalArgs} --outfile ${outfile}`; diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index b7a2057e48..336b7141cf 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 { oxfmtExternalArgs } from "./bundle-externals.ts"; import { darwinBinariesForShell, MACOS_IDENTIFIERS } from "./macos-signing.ts"; const MUSL_TARGETS = [ @@ -152,6 +153,7 @@ async function buildTarget(target: (typeof TARGETS)[number]) { `--define=SUPABASE_LIBC=${JSON.stringify(libc)}`, serveMainTemplateDefine, ...posthogBuildDefines, + ...oxfmtExternalArgs, `--outfile=${outfile}`, ]); console.log(`[${target.pkg}] Done.`); @@ -301,6 +303,7 @@ async function buildMuslBinaries() { `--define=SUPABASE_LIBC=${JSON.stringify(libc)}`, serveMainTemplateDefine, ...posthogBuildDefines, + ...oxfmtExternalArgs, `--outfile=${outfile}`, ]); diff --git a/apps/cli/scripts/bundle-externals.ts b/apps/cli/scripts/bundle-externals.ts new file mode 100644 index 0000000000..39816fd353 --- /dev/null +++ b/apps/cli/scripts/bundle-externals.ts @@ -0,0 +1,22 @@ +/** + * Optional prettier plugins that `oxfmt`'s dist lazily `import()`s for + * non-TypeScript file types (liquid, pug, astro, …). They are not installed — + * gen types only ever formats generated TypeScript, through the statically + * embedded binding in `src/legacy/commands/gen/types/types.oxfmt.ts` — but + * `bun build` still tries to resolve every analyzable dynamic import, so each + * one must be marked external for the compile to succeed. Shared by the dev + * build (`build-binary.ts`) and the multi-target release build (`build.ts`). + */ +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; + +export const oxfmtExternalArgs = OXFMT_OPTIONAL_PLUGIN_EXTERNALS.map( + (name) => `--external=${name}`, +); diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index 3875937f18..338386101f 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -2,23 +2,23 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------- | ---------- | ---------------------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | -| `/supabase/config.toml` | TOML | when selecting schemas; `--local` uses embedded defaults when the file is missing | -| `{/supabase}/.env*` | dotenv | `--local`; resolves the same nested environment overrides as the legacy 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 | +| Path | Format | When | +| --------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` or `--project-id` | +| `/supabase/config.toml` | TOML | when selecting schemas; `--local` uses embedded defaults when the file is missing | +| `{/supabase}/.env*` | dotenv | `--local` (nested env overrides) and `--db-url` (shared resolver layers project env under shell `PG*` fallbacks before parsing the DSN) | +| `/supabase/.temp/rest-version` | plain text | `--local` only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | +| `.pgpass` / `pg_service.conf` | libpq | `--db-url` only, when the DSN, `PGPASSFILE`/`PGSERVICEFILE`, or libpq defaults reference them | +| `$PGSSLROOTCERT` CA bundle | PEM | `--db-url` only, when the DSN or `PGSSLROOTCERT` sets `sslrootcert` | +| `$PGSSLCERT` / `$PGSSLKEY` | PEM | `--db-url` only, when the DSN or `PGSSLCERT`/`PGSSLKEY` set a client cert pair | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `$TMPDIR/supabase-gen-types-ca-*/root.crt` | PEM | remote native generation whose DSN has no explicit `sslmode` and the SSL probe reports TLS — scoped temp file of the embedded Supabase CA bundle, removed when generation finishes | -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 project files are written. Container env is not used. ## API Routes @@ -35,43 +35,78 @@ 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. +host/port and credentials for native generation. 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 -pg-meta generation retries once through the IPv4 pooler only when the current target +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. +## Database Access + +Except for the project-ref TypeScript path (Management API), types are generated +in-process by `@supabase/postgrest-typegen`: the CLI opens a direct Postgres +connection to the target database (the shared driver layer handles TLS for +remote targets and the `--dns-resolver` DoH mode), runs the package's +introspection queries against `pg_catalog`/`information_schema`, and renders the +requested language locally. `--query-timeout` is applied as the session's +`statement_timeout` (the flag wins over a DSN `statement_timeout`) and as a +client-side bound around `introspect()`; `0` / `0s` / `0ms` disable both. A +positive duration that rounds below 1s (`1ms`, `400ms`) is rejected rather than +treated as disable. When the connection string carries no explicit +`connect_timeout`, a positive `--query-timeout` is also used as the connect +timeout — `0` leaves the driver's default (10s remote, 2s local). `--local` +connects to the host-mapped database port from `supabase/config.toml` +(`db.port`). Remote connections that use a `supabase_admin` or `cli_login_*` +role step down to `postgres` via the shared driver (`SET SESSION ROLE +postgres`) before introspection. + +For a remote target whose DSN carries no explicit `sslmode`, a raw TCP +`SSLRequest` probe (the shared pg-delta probe, default 10s timeout) is opened +to the target host/port first: a server that does not speak SSL is connected +with `sslmode=disable`, so plain-TCP databases (common when self-hosting) +keep working as they did with pg-meta. A server that speaks TLS is connected +with `sslmode=require` plus the embedded Supabase CA bundle (the driver +promotes `require` + a root cert to `verify-ca`), matching the retired +`PG_META_DB_SSL_ROOT_CERT` injection. A probe failure (timeout, refused +connect, unexpected SSLRequest byte) fails the command rather than falling +through to the driver's unverified TLS default. Unix-socket hosts skip the +probe. An explicit `sslmode` on the DSN skips the probe entirely. If +`sslmode` is omitted, a successful TLS probe replaces any DSN/`PGSSLROOTCERT` +`sslrootcert` with the embedded bundle. + +`--network-id` / `SUPABASE_NETWORK_ID` are unused: generation no longer runs +inside a container, so a hostname reachable only on a Docker network will not +resolve. An explicit `--network-id` prints a warning with a +`docker run --network … npx supabase gen types` workaround. `--local` uses the +published host port instead; `--db-url` must be host-reachable. + ## Subprocesses -| Command | When | Purpose | -| -------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `docker`/`podman container inspect supabase_db_` | `--local` | assert `supabase start` is running | -| `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` | 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. +Type generation itself runs no subprocess and pulls no container image. ## 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) | 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 | +| 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 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 for the local database connection | no (defaults to `127.0.0.1`) | +| `SUPABASE_NETWORK_ID` | unused (native generation does not join a Docker network) | no | +| libpq vars (`PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`, `PGSSLKEY`, `PGSSLPASSWORD`, `PGCONNECT_TIMEOUT`, `PGSERVICE`, `PGSERVICEFILE`, `PGPASSFILE`, `PGAPPNAME`, …) | `--db-url` connection fallbacks, service/passfile, and TLS files | no | ## Exit Codes @@ -83,14 +118,16 @@ timeout. | `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` | API error, TLS probe failure, or pg-meta container non-zero exit | +| `1` | API error, connection failure, or introspection/generation error | ## Output ### `--output-format text` -Prints generated TypeScript (or other language) type definitions to stdout. -Diagnostics (`Connecting to …`, pg-meta logs) go to stderr. +Prints generated TypeScript (or other language) type definitions to stdout, +followed by a single trailing newline (the same shape the retired pg-meta +container produced via `console.log`). Diagnostics (`Connecting to …`) go to +stderr. ### `--output-format json` @@ -111,18 +148,18 @@ Not applicable. - **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 generate natively against 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 +Try using --db-url flag instead.` and never generated from 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 - with defaults (`internal` access control, one-to-one detection on, 15s timeout). On the + 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 honored 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, @@ -131,19 +168,41 @@ go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only - `--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 - regardless of `--lang`. -- `--postgrest-v9-compat` generates types compatible with PostgREST v9 and below. + `--db-url`, and implicit-linked-fallback paths it is always forwarded to the + generator regardless of `--lang`. +- `--postgrest-v9-compat` generates types compatible with PostgREST v9 and below + (one-to-one relationship detection disabled in the TypeScript generator). 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 - `--lang go`/`--lang swift`/`--lang python` (silently unused only for the implicit - linked TypeScript case, since that path never runs pg-meta). +- `--query-timeout` sets the maximum timeout for the introspection queries (default + 15s). Parsed as a Go `time.Duration` (bare `0` is valid and disables). A + positive value that rounds to 0 seconds is rejected — use `0` to disable, or + at least `500ms`. It is mutually exclusive with an _explicit_ `--linked`/ + `--project-id`; on the implicit linked fallback it is accepted, and honored + for `--lang go`/`--lang swift`/`--lang python` (silently unused only for the + implicit linked TypeScript case, since that path never opens a database + connection). +- `--db-url` is parsed by the shared connection resolver (libpq keywords, `PG*` env + fallbacks, `options=reference=` pooler tenants, `sslmode`), matching every + other `--db-url` command. An absent dbname follows libpq (`PGDATABASE`, then the + connection user) rather than forcing `postgres`. - The legacy positional language argument (`supabase gen types typescript`) is still accepted; any other positional language requires an explicit `--lang` flag. +- Go output lists entities in the canonical sorted order + (`sortGeneratorMetadata`) instead of pg-meta's environment-dependent SQL row + order; the rendered structs are otherwise the same. Swift is byte-identical + (its template sorts internally). Python is not a drop-in of pg-meta: it emits + `JsonValue` instead of pydantic `Json`, and imports `NotRequired`/`TypeAlias` + from `typing` rather than `typing_extensions`. TypeScript is formatted by + oxfmt (postgrest-typegen ≥ 0.2.0) instead of pg-meta's prettier, and native + generation differs in shape from both pg-meta and the Management API + `--linked`/`--project-id` TypeScript path: empty-arg RPCs are + `Args: Record` rather than `never`; some `Json` fields + become `NonNullable`; generated `path_tokens` is `never` rather than + `string[] | null`. The Management API payload also injects + `__InternalSupabase.PostgrestVersion`, which native `--local`/`--db-url` + generation omits. - The linked-project telemetry cache is written only when a project ref is resolved (`--linked`/`--project-id`/fallback) — it's skipped when no ref is available. diff --git a/apps/cli/src/legacy/commands/gen/types/templates/prod-ca-2021.ts b/apps/cli/src/legacy/commands/gen/types/templates/prod-ca-2021.ts deleted file mode 100644 index bb81be6980..0000000000 --- a/apps/cli/src/legacy/commands/gen/types/templates/prod-ca-2021.ts +++ /dev/null @@ -1,24 +0,0 @@ -export default `-----BEGIN CERTIFICATE----- -MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL -BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l -dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh -c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow -azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD -YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug -Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW -QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q -DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2 -GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi -cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4 -O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt -NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX -uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt -aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU -tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b -VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6 -jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx -Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2 -CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P -o/bKiIz+Fq8= ------END CERTIFICATE----- -`; diff --git a/apps/cli/src/legacy/commands/gen/types/templates/prod-ca-2025.ts b/apps/cli/src/legacy/commands/gen/types/templates/prod-ca-2025.ts deleted file mode 100644 index cbc3299123..0000000000 --- a/apps/cli/src/legacy/commands/gen/types/templates/prod-ca-2025.ts +++ /dev/null @@ -1,24 +0,0 @@ -export default `-----BEGIN CERTIFICATE----- -MIIDxzCCAq+gAwIBAgIUeX+gpfmsRW9asFkRvjyXjHxbfgcwDQYJKoZIhvcNAQEL -BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l -dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh -c2UgUm9vdCAyMDIxIENBMB4XDTI1MDkwMzA4MDEyNVoXDTM1MDkwMTA4MDEyNVow -azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD -YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug -Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Ve7 -i9UAmc7luUilELPtqzEk8nGHxg7nY0aCStr625M7+K4OPO6RUllTsHh47k1jWyzm -LXLlyYwCsYCjQp+3vn06H+F/HRUxBt6CK2B7bNng230exTunk0xFvfkX6YgHR7B3 -1B7L25Rq3PhuRFPV4hnGYRam2XBZC4UNPqoAgrhV0HOYzXXAVoTr2yaBTMnB331Z -RwOmINh7eqTCk/JRZbb6vfZOhZRAVAe9AoRLoG8aKwmeoLGwlu0UuFx6z3E+6bmA -fSNa8Lx02GEoCdPLw9IRKUFq/SgBpQUKm44H1fDwTjH2CMM0N4p0mL/6wXnNeHvt -C40MmKZ0RcVmHE5wBwIDAQABo2MwYTAdBgNVHQ4EFgQUjvEE541toZcwtXQlZlcB -YOBRTnowHwYDVR0jBBgwFoAUjvEE541toZcwtXQlZlcBYOBRTnowDwYDVR0TAQH/ -BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggEBACD5IcGP -XKvS9qg0CgEQPFqYavt5c7P+0xxFgiZe+xoG8fUw58yNeK2APtgGPRpxEOGfAlNx -z9HDt4gcyHEE00B3qAVDm49pqNxioFWzNqU2LGfM/HL1QmN6urR7hCOkVCJddvOc -FhFX4nZDuRfaBboDvS5HlK3Pzxddp9hvrJi2bemr8HLqYc3HzmVckgPGSLML6t+h -4LRCXSlQsDgQ1LZ4KHsl4cq7K51N6FOXQBLB5q4lMKhs0VUhCT8Pdsj12+84laCV -c22q6p2mdT9SaernCSRnWazXWisgpjv3H7Ex4S1DCYjJIwn3PUToGFv1r8YRN2/S -O19yVSxxCIf64Sg= ------END CERTIFICATE----- -`; diff --git a/apps/cli/src/legacy/commands/gen/types/templates/staging-ca-2021.ts b/apps/cli/src/legacy/commands/gen/types/templates/staging-ca-2021.ts deleted file mode 100644 index bd30915224..0000000000 --- a/apps/cli/src/legacy/commands/gen/types/templates/staging-ca-2021.ts +++ /dev/null @@ -1,24 +0,0 @@ -export default `-----BEGIN CERTIFICATE----- -MIID1DCCArygAwIBAgIUbYRdq/8/uNq8G9stMCdOFSBgA2MwDQYJKoZIhvcNAQEL -BQAwczELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l -dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEmMCQGA1UEAwwdU3VwYWJh -c2UgU3RhZ2luZyBSb290IDIwMjEgQ0EwHhcNMjEwNDI4MTAzNjEzWhcNMzEwNDI2 -MTAzNjEzWjBzMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRGVsd2FyZTETMBEGA1UE -BwwKTmV3IENhc3RsZTEVMBMGA1UECgwMU3VwYWJhc2UgSW5jMSYwJAYDVQQDDB1T -dXBhYmFzZSBTdGFnaW5nIFJvb3QgMjAyMSBDQTCCASIwDQYJKoZIhvcNAQEBBQAD -ggEPADCCAQoCggEBAN0AKRE8a56O8LaZxiOAcHFUFnwiKUvPoXPq26Ifw+Nv+7zg -N2V5WnMZbbw24q61Os60ZUn0XmbVtuIeJ+stPHsO7qxxuL+bmPR+qU5tkDrIOyEe -YD/2u8/q6ssVv42k4XcXbhM6RVz7CkCDY0TiBm1bMtRZso3xB6E9wAjxDf43XfV5 -PAGs3JI+Zo/vyqCDlN0hHOrB/aBl01JXqQWI84Gia5ooucq4SjA1CyawBcQ2IAvG -rXuy1BouY+xM3zRuNvtfFP6rb5Mta+jCYEMh1AZ8yP8sYUWAyhxX6k9EbOb009wQ -aZljbUCh/UglGWuBxdzePavx+zPjzWXB1NyVkpkCAwEAAaNgMF4wCwYDVR0PBAQD -AgEGMB0GA1UdDgQWBBQFx+PHLf27iIo/PMfIfGqXF7Zb+DAfBgNVHSMEGDAWgBQF -x+PHLf27iIo/PMfIfGqXF7Zb+DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB -CwUAA4IBAQB/xIiz5dDqzGXjqYqXZYx4iSfSxsVayeOPDMfmaiCfSMJEUG4cUiwG -OvMPGztaUEYeip5SCvSKuAAjVkXyP7ahKR7t7lZ9mErVXyxSZoVLbOd578CuYiZk -OgT17UjPv66WMzEKEr8wGpomTYWWfEkuqt8ENdiM1Z4LNFahdKj36+jm6/a+9R8K -25VIL68DTaQpBxFWG6ixC1HRMHJ12lDhKsshIi099BVpkGibESlxPrQOdKKqBB/J -vIX+/Hb+mS4H5zYMeK2wX0onp+GBcD6X9L1UJuXMVd+BRan8RFidXL5s3++xXjQq -Nzbc6lnA69urKffvcT07YwMsY/OmHzVa ------END CERTIFICATE----- -`; diff --git a/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts index 99e7ceacf9..c8c25e82fe 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts @@ -15,7 +15,6 @@ import { 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]; @@ -209,26 +208,12 @@ async function waitForLocalPostgres(containerName: string) { ); } -// `gen types` starts pg-meta itself (local AND remote non-ts languages) via a -// single-registry rewrite with no fallback (`resolvePgmetaImage`), so pre-resolve -// it and retag the winning candidate onto the exact reference the CLI will run. -async function ensurePgmetaImage(deadline?: number) { - const expected = resolvePgmetaImage(); - const resolved = await ensureImage(dockerfileServiceImage("pgmeta"), deadline); - if (resolved !== expected) { - await expectDockerSucceeded(["tag", resolved, expected], 30_000); - } -} - async function startLocalPostgres(input: { readonly projectId: string; readonly dbPort: number }) { const containerName = localDbContainerId(input.projectId); const networkName = localNetworkId(input.projectId); - // One shared window (already counted in the local test's timeout), with - // pg-meta's slice reserved up front: Postgres may spend the window only up - // to the point that still leaves pg-meta the default budget. + // One shared window, already counted in the local test's timeout. 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( @@ -404,8 +389,6 @@ describe("legacy 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/legacy/commands/gen/types/types.errors.ts b/apps/cli/src/legacy/commands/gen/types/types.errors.ts index 1285e3da84..2f4ac908e7 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.errors.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.errors.ts @@ -39,12 +39,26 @@ export class LegacyInvalidGenTypesDurationError extends Data.TaggedError( } } -export class LegacyInvalidGenTypesDatabaseUrlError extends Data.TaggedError( - "LegacyInvalidGenTypesDatabaseUrlError", -)<{ +/** + * A `postgrest-typegen` introspection query failed against a live database + * the CLI successfully connected to. Schema-derived, so a database finding. + */ +export class LegacyGenTypesMetadataError extends Data.TaggedError("LegacyGenTypesMetadataError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; + return actionability.dbFinding; + } +} + +/** + * Language generation or formatting failed after introspection succeeded — + * a CLI packaging / formatter / template defect, not a user schema finding. + */ +export class LegacyGenTypesGenerateError extends Data.TaggedError("LegacyGenTypesGenerateError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; } } diff --git a/apps/cli/src/legacy/commands/gen/types/types.generator.layer.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.integration.test.ts new file mode 100644 index 0000000000..8a9be442e0 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.integration.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer } from "effect"; + +import { + LegacyPgDeltaSslProbe, + LegacyPgDeltaSslProbeError, +} from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { legacyGenTypesGeneratorLayer } from "./types.generator.layer.ts"; +import { LegacyGenTypesGenerator } from "./types.generator.ts"; + +function failingProbeLayer(message: string) { + return Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.fail(new LegacyPgDeltaSslProbeError({ message })), + requireSslForHost: () => Effect.fail(new LegacyPgDeltaSslProbeError({ message })), + }); +} + +describe("legacyGenTypesGeneratorLayer", () => { + it.live("fails closed when the remote SSL probe cannot determine TLS capability", () => { + const layer = legacyGenTypesGeneratorLayer.pipe( + Layer.provide(failingProbeLayer("connection refused")), + Layer.provide(BunServices.layer), + ); + + return Effect.gen(function* () { + const generator = yield* LegacyGenTypesGenerator; + const exit = yield* generator + .generate({ + conn: { + host: "db.example.com", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", + }, + isLocal: false, + dnsResolver: "https", + lang: "go", + includedSchemas: [], + postgrestV9Compat: false, + swiftAccessControl: "internal", + queryTimeoutSeconds: 15, + }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("LegacyPgDeltaSslProbeError"); + expect(String(exit.cause)).toContain("connection refused"); + } + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts new file mode 100644 index 0000000000..a3680e1d5c --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts @@ -0,0 +1,151 @@ +import { + generateGo, + generatePython, + generateSwift, + generateTypescript, + sortGeneratorMetadata, +} from "@supabase/postgrest-typegen/generation"; +import { introspect } from "@supabase/postgrest-typegen/introspection"; +import { Duration, Effect, FileSystem, Layer, Path } from "effect"; + +import { + legacyAcquirePgPool, + legacyIsUnixSocketHost, +} from "../../../shared/legacy-db-connection.sql-pg.layer.ts"; +import { LEGACY_PG_DELTA_CA_BUNDLE } from "../../../shared/legacy-pgdelta-ssl.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { LegacyGenTypesGenerateError, LegacyGenTypesMetadataError } from "./types.errors.ts"; +import { type LegacyGenTypesGenerateInput, LegacyGenTypesGenerator } from "./types.generator.ts"; +import { legacyOxfmtTypegenFormat } from "./types.oxfmt.ts"; +import { applyProbedSslMode, applyQueryTimeouts } from "./types.shared.ts"; + +function describeCause(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +const pinProbedCaBundle = (fs: FileSystem.FileSystem, path: Path.Path) => + Effect.gen(function* () { + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-gen-types-ca-" }); + const caPath = path.join(dir, "root.crt"); + yield* fs.writeFileString(caPath, LEGACY_PG_DELTA_CA_BUNDLE); + return caPath; + }).pipe( + Effect.mapError( + (cause) => + new LegacyGenTypesGenerateError({ + message: `failed to write TLS CA bundle: ${describeCause(cause)}`, + }), + ), + ); + +const generate = ( + sslProbe: LegacyPgDeltaSslProbe["Service"], + fs: FileSystem.FileSystem, + path: Path.Path, + input: LegacyGenTypesGenerateInput, +) => + Effect.scoped( + Effect.gen(function* () { + let conn = applyQueryTimeouts(input.conn, input.queryTimeoutSeconds); + // Remote TCP DSNs without sslmode probe first: no TLS → disable; + // TLS → require + the CA pin. Probe failure fails closed so a + // DoH-only / broken-native-DNS environment cannot fall through to + // the driver's unverified TLS default. Unix sockets skip the probe + // — the driver never speaks TLS on a socket. + if (!input.isLocal && conn.sslmode === undefined && !legacyIsUnixSocketHost(conn.host)) { + const useTls = yield* sslProbe.requireSslForHost(conn.host, conn.port); + if (!useTls) { + conn = applyProbedSslMode(conn, false); + } else { + const sslrootcert = yield* pinProbedCaBundle(fs, path); + conn = applyProbedSslMode(conn, true, sslrootcert); + } + } + + const pool = yield* legacyAcquirePgPool(conn, { + isLocal: input.isLocal, + dnsResolver: input.dnsResolver, + }); + + // `introspect` drives the injected queryable itself, so the foreign + // Promise boundary is wrapped exactly once here; a live `pg.Pool` + // satisfies its `Queryable` contract directly. `statement_timeout` + // only bounds server-side execution — also cap the client wait so a + // stalled network cannot hang past `--query-timeout`. + const introspectEffect = Effect.tryPromise({ + try: () => + introspect( + pool, + input.includedSchemas.length > 0 ? { includedSchemas: [...input.includedSchemas] } : {}, + ), + catch: (cause) => + new LegacyGenTypesMetadataError({ + message: `failed to introspect database: ${describeCause(cause)}`, + }), + }); + const metadata = + input.queryTimeoutSeconds > 0 + ? yield* introspectEffect.pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(input.queryTimeoutSeconds), + orElse: () => + Effect.fail( + new LegacyGenTypesMetadataError({ + message: `introspection exceeded --query-timeout ${input.queryTimeoutSeconds}s`, + }), + ), + }), + ) + : yield* introspectEffect; + + // Canonical sort before generation so output is deterministic regardless + // of the introspection queries' heap order. + const sorted = sortGeneratorMetadata(metadata); + + const generateError = (cause: unknown) => + new LegacyGenTypesGenerateError({ + message: `failed to generate ${input.lang} types: ${describeCause(cause)}`, + }); + + switch (input.lang) { + case "typescript": + return yield* Effect.tryPromise({ + try: () => + generateTypescript(sorted, { + detectOneToOneRelationships: !input.postgrestV9Compat, + // The statically-embedded oxfmt binding (see types.oxfmt.ts); + // the package's own default formatter cannot load its native + // addon inside the compiled binary. + format: legacyOxfmtTypegenFormat, + }), + catch: generateError, + }); + case "go": + return yield* Effect.try({ try: () => generateGo(sorted), catch: generateError }); + case "python": + return yield* Effect.try({ try: () => generatePython(sorted), catch: generateError }); + case "swift": + return yield* Effect.try({ + try: () => generateSwift(sorted, { accessControl: input.swiftAccessControl }), + catch: generateError, + }); + } + }), + ); + +/** + * Production `LegacyGenTypesGenerator`: a scoped `pg.Pool` with the shared + * driver-layer connection parity (TLS mode, DoH resolver, fallback hosts), + * introspected and rendered by `@supabase/postgrest-typegen`. + */ +export const legacyGenTypesGeneratorLayer = Layer.effect( + LegacyGenTypesGenerator, + Effect.gen(function* () { + const sslProbe = yield* LegacyPgDeltaSslProbe; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return { + generate: (input: LegacyGenTypesGenerateInput) => generate(sslProbe, fs, path, input), + }; + }), +); diff --git a/apps/cli/src/legacy/commands/gen/types/types.generator.ts b/apps/cli/src/legacy/commands/gen/types/types.generator.ts new file mode 100644 index 0000000000..0809e15658 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.ts @@ -0,0 +1,64 @@ +import { Context, type Effect } from "effect"; + +import type { LegacyDbConnectError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyPgDeltaSslProbeError } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import type { LegacyGenTypesGenerateError, LegacyGenTypesMetadataError } from "./types.errors.ts"; + +export type LegacyGenTypesLang = "typescript" | "go" | "swift" | "python"; + +export interface LegacyGenTypesGenerateInput { + /** The database to introspect. */ + readonly conn: LegacyPgConnInput; + /** Whether `conn` targets the local stack (drives the driver's TLS mode). */ + readonly isLocal: boolean; + /** The active `--dns-resolver` value, forwarded to the driver layer. */ + readonly dnsResolver: "native" | "https"; + readonly lang: LegacyGenTypesLang; + /** Schemas to include; empty means the introspector's own default set. */ + readonly includedSchemas: ReadonlyArray; + /** + * `--postgrest-v9-compat`: disables one-to-one relationship detection in the + * TypeScript generator (ignored by the other languages), matching the + * `PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=!v9compat` env the + * retired pg-meta container path received. + */ + readonly postgrestV9Compat: boolean; + /** `--swift-access-control` (Swift generator only). */ + readonly swiftAccessControl: "internal" | "public"; + /** + * `--query-timeout` in whole seconds. Applied as session `statement_timeout`, + * a client-side bound around `introspect()`, and — when the DSN has no + * `connect_timeout` and this value is positive — the connect timeout. + * `0` disables the query bounds and leaves the driver's connect default. + */ + readonly queryTimeoutSeconds: number; +} + +interface LegacyGenTypesGeneratorShape { + /** + * Connect to `conn`, introspect it with `@supabase/postgrest-typegen`, and + * render the generated types for `lang`. The returned string is the exact + * generator output (no trailing newline added). + */ + readonly generate: ( + input: LegacyGenTypesGenerateInput, + ) => Effect.Effect< + string, + | LegacyDbConnectError + | LegacyGenTypesGenerateError + | LegacyGenTypesMetadataError + | LegacyPgDeltaSslProbeError + >; +} + +/** + * Native type generation for `gen types`, backed by + * `@supabase/postgrest-typegen` over a real Postgres connection. A service so + * handler integration tests can replace the live database + generator with a + * recording fake. + */ +export class LegacyGenTypesGenerator extends Context.Service< + LegacyGenTypesGenerator, + LegacyGenTypesGeneratorShape +>()("supabase/legacy/GenTypesGenerator") {} diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 47f5a74803..bde8a5dae1 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -1,10 +1,7 @@ import { loadCliConfig } from "@supabase/config/internal"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; -import { - LegacyDnsResolverFlag, - LegacyNetworkIdFlag, -} from "../../../../shared/legacy/global-flags.ts"; +import { Effect, FileSystem, Option, Path, Stdio } from "effect"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, @@ -18,11 +15,8 @@ import { LegacyProjectRefResolver, PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/legacy-project-ref.service.ts"; -import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; -import { - legacyIsIPv6ConnectivityError, - legacyIsIPv6ConnectivityErrorCause, -} from "../../../shared/legacy-connect-errors.ts"; +import { legacyCollectText, spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyIsIPv6ConnectivityErrorCause } from "../../../shared/legacy-connect-errors.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConfigFlags } from "../../../shared/legacy-db-config.types.ts"; @@ -32,29 +26,25 @@ import { legacyReadDbToml, } from "../../../shared/legacy-db-config.toml-read.ts"; import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; -import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; +import { legacyPflagStringValue } from "../../../shared/legacy-pflag-reconcile.ts"; import { legacyTempPaths } from "../../../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { legacyIsDirectDbHost, legacyRunWithPoolerFallback, } from "../../../shared/legacy-pooler-fallback.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; import { LegacyGenTypesNetworkError, LegacyGenTypesUnexpectedStatusError } from "./types.errors.ts"; +import { LegacyGenTypesGenerator } from "./types.generator.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { defaultSchemas, - buildPostgresUrl, localDbContainerId, localDbPassword, - localNetworkId, - parseDatabaseUrl, + legacyGenTypesNetworkIdUnusedWarning, parseQueryTimeoutSeconds, - legacyRootCaBundle, - resolvePgmetaImage, } from "./types.shared.ts"; const mapProjectTypesError = mapLegacyHttpError({ @@ -135,48 +125,10 @@ 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( - stream, - () => "", - (text, chunk) => text + decoder.decode(chunk, { stream: true }), - ).pipe(Effect.map((text) => text + decoder.decode())); -} - -// Keep these two sets in sync with the value-bearing flags on the root command -// (shared/legacy/global-flags.ts) and the `gen types` command (types.command.ts). -// They let `findLegacyPositionalLanguage` skip a flag's value so it is not -// mistaken for the legacy positional language argument (e.g. `gen types typescript`). -const LONG_FLAGS_WITH_VALUES = new Set([ - "db-url", - "project-id", - "lang", - "schema", - "swift-access-control", - "query-timeout", - "profile", - "workdir", - "network-id", - "dns-resolver", - "output", - "output-format", - "log-level", - "completions", - "agent", -]); - -const SHORT_FLAGS_WITH_VALUES = new Set(["s", "o"]); +// Positional `typescript` scanning must skip the same value tokens pflag +// consumes — derive from the scan spec so a new value flag cannot drift. +const LONG_FLAGS_WITH_VALUES = GEN_TYPES_SCAN_SPEC.valueFlagNames; +const SHORT_FLAGS_WITH_VALUES = new Set(GEN_TYPES_SCAN_SPEC.valueFlagShorthands.keys()); function findLegacyPositionalLanguage(rawArgs: ReadonlyArray): Option.Option { const commandIndex = rawArgs.findIndex( @@ -226,7 +178,6 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stdio = yield* Stdio.Stdio; - const networkId = yield* LegacyNetworkIdFlag; const dnsResolver = yield* LegacyDnsResolverFlag; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const rawArgs = yield* stdio.args; @@ -234,7 +185,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const projectRef = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const dbConfig = yield* LegacyDbConfigResolver; - const sslProbe = yield* LegacyPgDeltaSslProbe; + const generator = yield* LegacyGenTypesGenerator; // "Set" follows cobra's `pflag.Changed` semantics — whether the flag was // passed at all — not the resulting value: `--linked=false` still counts @@ -252,8 +203,6 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le // followed by a telemetry flush. const queryTimeoutSeconds = yield* parseQueryTimeoutSeconds(flags.queryTimeout); - // flags.schema is already CSV-parsed and validated by `Flag.mapTryCatch(legacyParseSchemaFlags)` - // in types.command.ts — use it directly. const schemas = flags.schema; const lang = flags.lang; const swiftAccessControl = flags.swiftAccessControl; @@ -297,19 +246,16 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le adHocProjectRef, }; const resolved = yield* dbConfig.resolve(resolveFlags); - const conn = resolved.conn; - yield* runPgMeta({ - url: legacyToPostgresURL(conn), - host: conn.host, - port: conn.port, - probeHost: conn.host, - probePort: conn.port, - networkMode: "host", - includedSchemas: includedSchemas.join(","), + yield* runTypegen({ + conn: resolved.conn, + isLocal: resolved.isLocal, + includedSchemas, postgrestV9Compat: flags.postgrestV9Compat, poolerFallback: { - directHost: conn.host, - eligible: !resolved.isLocal && legacyIsDirectDbHost(conn.host, cliSettings.projectHost), + directHost: resolved.conn.host, + eligible: + !resolved.isLocal && + legacyIsDirectDbHost(resolved.conn.host, cliSettings.projectHost), resolve: dbConfig.resolvePoolerFallback(resolveFlags), }, }); @@ -355,20 +301,16 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le Effect.orElseSucceed(() => Option.none()), ); - yield* runPgMeta({ - url: legacyToPostgresURL({ + yield* runTypegen({ + conn: { 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(","), + }, + isLocal: false, + includedSchemas, postgrestV9Compat: flags.postgrestV9Compat, poolerFallback: { directHost: branch.db_host, @@ -378,129 +320,51 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le }); }); - 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; + const runTypegen = (input: { + readonly conn: LegacyPgConnInput; + readonly isLocal: boolean; + readonly includedSchemas: ReadonlyArray; readonly postgrestV9Compat: boolean; - readonly pgmetaVersionOverride?: string; readonly poolerFallback?: { readonly directHost: string; readonly eligible: boolean; readonly resolve: Effect.Effect, unknown>; }; }) => - Effect.scoped( - Effect.gen(function* () { - 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"); - - // Each entry is a "KEY=VALUE" string, passed as a `--env - // KEY=VALUE` argument rather than a `--env-file`: env-files - // split on newlines, so they cannot carry the multi-line PEM CA - // bundle, and a value containing a newline could inject an extra - // variable. Passing argv elements keeps each entry as exactly - // one variable regardless of its contents. - 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)}`, - ]; - - // Emitted to stderr when the probe runs with certificate - // verification disabled. Our wire-level SSLRequest probe never - // verifies certificates, so honour the same env var here too. - if (process.env["SUPABASE_CA_SKIP_VERIFY"] === "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=${legacyRootCaBundle()}`); - } - - // `--network-id` overrides any base network mode (even the - // "host" mode used for --db-url), so honour the override here too. - const networkMode = Option.isSome(networkId) ? networkId.value : input.networkMode; - const pgmetaImage = resolvePgmetaImage(input.pgmetaVersionOverride); - const args = [ - "run", - "--rm", - "--network", - networkMode, - ...env.flatMap((entry) => ["--env", entry]), - pgmetaImage, - "node", - "dist/server/server.js", - ]; - const child = yield* spawnContainerCli(spawner, args, { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - - 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 }; + Effect.gen(function* () { + const generateTarget = (conn: LegacyPgConnInput, isLocal: boolean) => + Effect.gen(function* () { + yield* output.raw(`Connecting to ${conn.host} ${conn.port}\n`, "stderr"); + return yield* generator.generate({ + conn, + isLocal, + dnsResolver, + lang, + includedSchemas: input.includedSchemas, + postgrestV9Compat: input.postgrestV9Compat, + swiftAccessControl, + queryTimeoutSeconds, }); + }); - const runTarget = (conn: LegacyPgConnInput) => - buildRun({ - url: legacyToPostgresURL(conn), - host: conn.host, - port: conn.port, - probeHost: conn.host, - probePort: conn.port, - }); + const types = + input.poolerFallback === undefined + ? yield* generateTarget(input.conn, input.isLocal) + : yield* legacyRunWithPoolerFallback({ + run: generateTarget(input.conn, input.isLocal), + // The pooler endpoint is always a remote target, even when the + // direct attempt was against a local-looking host. + retry: (pooler) => generateTarget(pooler, false), + directHost: input.poolerFallback.directHost, + eligible: input.poolerFallback.eligible, + resolveFallback: input.poolerFallback.resolve, + classifyError: legacyIsIPv6ConnectivityErrorCause, + }); - const result = - input.poolerFallback === undefined - ? yield* buildRun(input) - : yield* legacyRunWithPoolerFallback({ - run: buildRun(input), - retry: runTarget, - directHost: input.poolerFallback.directHost, - eligible: input.poolerFallback.eligible, - resolveFallback: input.poolerFallback.resolve, - classifyError: legacyIsIPv6ConnectivityErrorCause, - classifyResult: (result) => - result.exitCode !== 0 && legacyIsIPv6ConnectivityError(result.stderrText), - }); - - if (result.exitCode !== 0) { - return yield* Effect.fail(new Error(`error running container: exit ${result.exitCode}`)); - } - }), - ); + // pg-meta's `console.log` contract: exactly one trailing newline. + // oxfmt (and some language templates) already terminate. + yield* output.raw(types.replace(/\n*$/, "\n")); + }); const assertLocalDbRunning = (projectId: string) => Effect.scoped( @@ -519,7 +383,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ); const [exitCode, stderr] = yield* Effect.all([ child.exitCode.pipe(Effect.map(Number)), - collectByteStream(child.stderr), + legacyCollectText(child.stderr), ]); if (exitCode !== 0) { const message = stderr.trim(); @@ -582,6 +446,15 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le } } + // Persistent `--network-id` can appear before the command path + // (`supabase --network-id net gen types …`); last-wins matches workdir/profile. + const networkIdOverride = legacyPflagStringValue(occurrences, "network-id").pipe( + Option.orElse(() => legacyPflagStringValue(scan.prePathOccurrences, "network-id")), + ); + if (Option.isSome(networkIdOverride)) { + yield* output.warn(legacyGenTypesNetworkIdUnusedWarning(networkIdOverride.value)); + } + if (flags.local) { const config = yield* legacyReadDbToml(fs, path, cliSettings.workdir); yield* legacyApplyProjectEnv( @@ -593,10 +466,10 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ); const paths = legacyTempPaths(path, cliSettings.workdir); - // Go resolves Config.Api.Image from the rest-version file only when - // Db.MajorVersion > 14, then forces v9 compat when that image tag contains "v9" - // (pkg/config/config.go:657-666, internal/gen/types/types.go:69). Gate and trim - // identically so we don't force v9 on older databases. + // The local PostgREST image is resolved from the rest-version file only when + // Db.MajorVersion > 14; when that image tag contains "v9" the generated types + // must stay v9-compatible. Gate and trim identically so we don't force v9 on + // older databases. const restVersion = config.majorVersion > 14 ? (yield* fs @@ -604,49 +477,41 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le .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); yield* assertLocalDbRunning(projectId); - yield* runPgMeta({ - url: buildPostgresUrl({ - host: "db", - port: 5432, + yield* runTypegen({ + conn: { + host: legacyGetHostname(), + port: config.port, user: "postgres", password: localDbPassword(), database: "postgres", - }), - host: "db", - port: 5432, - probeHost: legacyGetHostname(), - probePort: config.port, - networkMode: localNetworkId(projectId), + }, + isLocal: true, includedSchemas, postgrestV9Compat: flags.postgrestV9Compat || forcedV9, - pgmetaVersionOverride, }); return; } if (Option.isSome(flags.dbUrl)) { const loaded = 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 includedSchemas = + schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas ?? []); + // The shared resolver parses the DSN pgconn-style (libpq keywords, + // `options=reference=…` pooler tenants, sslmode, PG* env fallbacks) and + // detects a local target, matching every other `--db-url` command. + const resolved = yield* dbConfig.resolve({ + dbUrl: flags.dbUrl, + connType: "db-url", + dnsResolver, + }); + + yield* runTypegen({ + conn: resolved.conn, + isLocal: resolved.isLocal, includedSchemas, postgrestV9Compat: flags.postgrestV9Compat, }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index a8f8f33e2f..29658c63d7 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -1,5 +1,4 @@ import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; @@ -11,28 +10,18 @@ import type { V1GetProjectOutput, } from "@supabase/api/effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { CliOutput, Command } from "effect/unstable/cli"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stdio, Stream } from "effect"; import { - LEGACY_GLOBAL_FLAGS, LegacyDebugFlag, LegacyDnsResolverFlag, - LegacyNetworkIdFlag, LegacyOutputFlag, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; -import { - mockAnalytics, - mockOutput, - mockProcessControl, - mockRuntimeInfo, - mockTty, - processEnvLayer, -} from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, LEGACY_VALID_REF, @@ -42,32 +31,21 @@ import { mockLegacyTelemetryStateTracked, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; -import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.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 { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbConnectError } from "../../../shared/legacy-db-connection.errors.ts"; import type { LegacyDbConfigError } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; -import { - LegacyPgDeltaSslProbe, - LegacyPgDeltaSslProbeError, -} from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import type { LegacyDbConfigFlags, LegacyResolvedDbConfig, } from "../../../shared/legacy-db-config.types.ts"; -import { legacyGenCommand } from "../gen.command.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; +import { LegacyGenTypesMetadataError } from "./types.errors.ts"; +import type { LegacyGenTypesGenerateInput } from "./types.generator.ts"; +import { LegacyGenTypesGenerator } from "./types.generator.ts"; import { legacyGenTypes } from "./types.handler.ts"; -import { - localDbContainerId, - localNetworkId, - parseQueryTimeoutSeconds, - resolvePgmetaImage, -} from "./types.shared.ts"; +import { localDbContainerId } from "./types.shared.ts"; function writeConfig(workdir: string, contents: string) { const supabaseDir = join(workdir, "supabase"); @@ -89,42 +67,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 = {}): LegacyGenTypesFlags { return { local: false, @@ -200,6 +142,30 @@ function mockDbConfigResolver( return { layer, resolves, poolerFallbacks }; } +/** + * Recording fake for the native typegen seam. Each `generate` call is captured; + * the nth call resolves with the nth entry of `results` (falling back to a + * plain `"generated"` success when the list is exhausted or absent). + */ +function mockLegacyGenTypesGenerator( + opts: { + readonly output?: string; + readonly results?: ReadonlyArray< + Effect.Effect + >; + } = {}, +) { + const calls: Array = []; + const layer = Layer.succeed(LegacyGenTypesGenerator, { + generate: (input) => + Effect.suspend(() => { + calls.push(input); + return opts.results?.[calls.length - 1] ?? Effect.succeed(opts.output ?? "generated"); + }), + }); + return { layer, calls }; +} + type BranchConfig = typeof V1GetABranchConfigOutput.Type; type LoginRole = typeof V1CreateLoginRoleOutput.Type; type PoolerConfig = typeof V1GetPoolerConfigOutput.Type; @@ -213,17 +179,19 @@ 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 args?: ReadonlyArray; + readonly generatorOutput?: string; + readonly generatorResults?: ReadonlyArray< + Effect.Effect + >; readonly generateTypescriptTypes?: (input: { readonly ref: string; readonly included_schemas?: string; @@ -244,7 +212,6 @@ function setup( ) => Effect.Effect; readonly poolerFallback?: Option.Option; readonly poolerFallbackFails?: boolean; - readonly sslProbeLayer?: Layer.Layer; } = {}, ) { const workdir = opts.workdir ?? mkdtempSync(join(tmpdir(), "supabase-gen-types-")); @@ -262,9 +229,13 @@ function setup( poolerFallback: opts.poolerFallback, poolerFallbackFails: opts.poolerFallbackFails, }); + const generator = mockLegacyGenTypesGenerator({ + output: opts.generatorOutput, + results: opts.generatorResults, + }); const processControl = mockProcessControl(); const child = mockChildProcessSpawner({ - stdout: [...(opts.childStdout ?? [])], + stdout: [], stderr: [...(opts.childStderr ?? [])], exitCode: opts.childExitCode ?? 0, onSpawn: opts.onSpawn, @@ -361,15 +332,11 @@ function setup( Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), Layer.succeed(LegacyDebugFlag, opts.debug ?? false), Layer.succeed(LegacyDnsResolverFlag, "native" as const), - Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), - opts.sslProbeLayer ?? - legacyPgDeltaSslProbeLayer.pipe( - Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), - ), Layer.succeed(LegacyPlatformApiFactory, { make: LegacyPlatformApi.pipe(Effect.provide(api.layer)), }), dbConfig.layer, + generator.layer, ); return { @@ -378,6 +345,7 @@ function setup( telemetry, linkedProjectCache, dbConfig, + generator, processControl, child, api, @@ -385,67 +353,6 @@ function setup( }; } -function mockSequentialChildProcessSpawner( - 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 }); - - 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; @@ -518,140 +425,24 @@ function mockDockerMissingChildProcessSpawner( }; } -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"); - } +const IPV6_CONNECT_FAILURE = new LegacyDbConnectError({ + message: `failed to connect to postgres: could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, +}); - try { - return await run(address.port); - } finally { - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } -} +const NATIVE_ENOTFOUND_CONNECT_FAILURE = new LegacyDbConnectError({ + message: `failed to connect to postgres: failed to connect to \`host=db.${LEGACY_VALID_REF}.supabase.co user=postgres database=postgres\`: hostname resolving error (getaddrinfo ENOTFOUND)`, +}); const nonTypescriptProjectRefScenarios = [ - { lang: "go", stdout: "type PublicMovies struct {}" }, - { lang: "swift", stdout: "struct PublicMovies: Codable {}" }, - { lang: "python", stdout: "class PublicMovies(BaseModel):" }, + { lang: "go", output: "type PublicMovies struct {}" }, + { lang: "swift", output: "struct PublicMovies: Codable {}" }, + { lang: "python", output: "class PublicMovies(BaseModel):" }, ] as const satisfies ReadonlyArray<{ readonly lang: Exclude; - readonly stdout: string; + readonly output: string; }>; -const legacyTestRoot = Command.make("supabase").pipe( - Command.withSubcommands([legacyGenCommand]), - Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), -); - describe("legacy 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); - }), - ); - - 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, 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(legacyTestRoot, { 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(2); - }), - 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(LEGACY_VALID_REF), @@ -814,9 +605,8 @@ describe("legacy gen types", () => { // this argv (both `local` and `linked` parse as independently true, // since its tokenizer is unaware of pflag's value consumption); only the // pflag-faithful scan can tell them apart. - // `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. + // `childExitCode: 1` fails the local target's `container inspect`, keeping + // the downstream failure deterministic before any generation runs. const { layer } = setup({ args: ["gen", "types", "-s", "--linked", "--local"], childExitCode: 1, @@ -1016,6 +806,28 @@ describe("legacy gen types", () => { }); }); + it.live("rejects a sub-second --query-timeout that would disable the bound", () => { + const { layer, telemetry } = setup({ + args: ["gen", "types", "--db-url", "postgresql://postgres@127.0.0.1:5432/postgres"], + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some("postgresql://postgres@127.0.0.1:5432/postgres"), + queryTimeout: "1ms", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain('invalid duration "1ms"'); + expect(String(exit.cause)).toContain("use 0 to disable, or at least 500ms"); + } + expect(telemetry.flushed).toBe(false); + }); + }); + it.live("silently ignores --query-timeout for implicit linked TypeScript generation", () => { const { layer, out, api } = setup({ args: ["gen", "types", "--query-timeout", "20s"], @@ -1038,56 +850,38 @@ describe("legacy gen types", () => { }); 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(LEGACY_VALID_REF), - childStdout: ["type PublicMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "workdir-password", - database: "postgres", - }), - ), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), - ).pipe(Effect.provide(layer)), - ); + "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(LEGACY_VALID_REF), + generatorOutput: "type PublicMovies struct {}", + }); - // Unlike an explicit --linked/--project-id, the implicit fallback never - // sets the "linked"/"project-id" mutex keys, so --query-timeout and - // --swift-access-control clear every guard here and reach pg-meta — the - // SIDE_EFFECTS.md defaults-invariant note is scoped to the explicit - // paths for exactly this reason. - 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))), - }), + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), + ).pipe(Effect.provide(layer)); + + // Unlike an explicit --linked/--project-id, the implicit fallback never + // sets the "linked"/"project-id" mutex keys, so --query-timeout and + // --swift-access-control clear every guard here and reach the + // generator — the SIDE_EFFECTS.md defaults-invariant note is scoped to + // the explicit paths for exactly this reason. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + expect(generator.calls[0]?.queryTimeoutSeconds).toBe(20); + expect(generator.calls[0]?.swiftAccessControl).toBe("public"); + }); + }, ); it.live("prefers the --postgrest-v9-compat guard over mutex group errors", () => { @@ -1164,1113 +958,817 @@ describe("legacy 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("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 } = setup({ - workdir, - args: [ - "gen", - "types", - "--local", - "--lang", - "python", - "--swift-access-control", - "public", - ], - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - // Go has no "--swift-access-control requires --lang swift" guard — - // the value is always forwarded to pg-meta regardless of language. - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), - ).pipe(Effect.provide(layer)), - ); + const { layer, generator } = setup({ + workdir, + args: ["gen", "types", "--local", "--lang", "python", "--swift-access-control", "public"], + }); - 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))), - }), - ); + // Go has no "--swift-access-control requires --lang swift" guard — + // the value is always forwarded to the generator regardless of language. + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), + ).pipe(Effect.provide(layer)); - 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( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - postgrestV9Compat: true, - }), - ).pipe(Effect.provide(layer)), - ); + expect(generator.calls[0]?.lang).toBe("python"); + expect(generator.calls[0]?.swiftAccessControl).toBe("public"); + }); + }); + + it.live("allows --postgrest-v9-compat together with --db-url", () => { + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const { layer, generator } = setup({ + args: ["gen", "types", "--db-url", dbUrl, "--postgrest-v9-compat"], + }); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), - ).toBe(true); + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(dbUrl), + postgrestV9Compat: true, }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); - for (const scenario of nonTypescriptProjectRefScenarios) { - it.live(`generates ${scenario.lang} types from a project ref through the DB resolver`, () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, out, child, api, linkedProjectCache, dbConfig } = setup({ - args: ["gen", "types", "--lang", scenario.lang, "--project-id", LEGACY_VALID_REF], - childStdout: [scenario.stdout], - dbConfigResolve: (input) => - Effect.succeed( - remoteResolvedConfig( - { - host: "127.0.0.1", - port, - user: `cli_login_${LEGACY_VALID_REF}`, - password: "temporary-password", - database: "postgres", - }, - (input.linkedProjectRef !== undefined - ? Option.getOrUndefined(input.linkedProjectRef) - : undefined) ?? LEGACY_VALID_REF, - ), - ), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.fail(new Error(`unexpected preview branch lookup for ${branch_id_or_ref}`)), - getProject: ({ ref }) => - Effect.succeed({ - id: ref, - ref, - organization_id: "org-id", - organization_slug: "org", - name: "demo", - region: "us-east-1", - created_at: "2025-01-01T00:00:00Z", - status: "ACTIVE_HEALTHY", - database: { - host: `127.0.0.1:${port}`, - version: "15.1", - postgres_engine: "15", - release_channel: "ga", - }, - }), - createLoginRole: ({ ref }) => - Effect.fail(new Error(`unexpected login role creation for ${ref}`)), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: scenario.lang, - }), - ).pipe(Effect.provide(layer)), - ); + expect(generator.calls[0]?.postgrestV9Compat).toBe(true); + }); + }); - expect(api.requests).toContainEqual({ - method: "getProject", - input: { ref: LEGACY_VALID_REF }, - }); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "createLoginRole" }), - ); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "getABranchConfig" }), - ); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "generateTypescriptTypes" }), - ); - expect(child.spawned[0]?.args).toContain("--network"); - expect(child.spawned[0]?.args).toContain("host"); - expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://cli_login_${LEGACY_VALID_REF}:temporary-password@127.0.0.1:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); - expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("linked"); - // --project-id is an ad-hoc remote ref: the resolver must not inherit - // the workdir's ambient password / saved pooler URL. - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); - const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; - expect( - linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, - ).toBe(LEGACY_VALID_REF); - expect(docker.env.has(`PG_META_GENERATE_TYPES=${scenario.lang}`)).toBe(true); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); - expect(out.stdoutText).toContain(scenario.stdout); - expect(linkedProjectCache.cached).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - } + it.live("warns that --network-id is unused and still generates", () => { + const dbUrl = "postgresql://postgres:postgres@db:5432/postgres"; + const { layer, generator, out } = setup({ + args: ["gen", "types", "--db-url", dbUrl, "--network-id", "mycompose_default"], + }); - it.live("resolves the linked workdir DB without ad-hoc project-ref semantics", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--linked"], - projectId: Option.some(LEGACY_VALID_REF), - childStdout: ["type PublicMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "workdir-password", - database: "postgres", - }), - ), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); - expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("linked"); - // --linked is the workdir's own project: keep workdir-scoped credentials. - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + expect(generator.calls).toHaveLength(1); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("docker run --rm --network mycompose_default"), }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - - it.live("preserves resolver URL options for remote non-TypeScript typegen", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childStdout: ["type PublicMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - options: `reference=${LEGACY_VALID_REF}`, - }), - ), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); - - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10&options=reference%3D${LEGACY_VALID_REF}`, - ), - ).toBe(true); + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("npx --yes supabase gen types"), }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - - 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: 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: LegacyPgConnInput = { - host: "127.0.0.1", - port, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }; - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some(poolerConn), - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).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(child.spawned).toHaveLength(2); - expect( - dockerEnv(child.spawned[0]?.args ?? []).has( - `PG_META_DB_URL=postgresql://postgres:direct-password@db.${LEGACY_VALID_REF}.supabase.co:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); - expect( - dockerEnv(child.spawned[1]?.args ?? []).has( - `PG_META_DB_URL=postgresql://postgres.${LEGACY_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("does not warn about --network-id when the flag is omitted", () => { + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const { layer, out } = setup({ + args: ["gen", "types", "--db-url", dbUrl], + }); - it.live("retries remote pg-meta through the IPv4 pooler on Node ENETUNREACH stderr", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: ["connect ENETUNREACH 2600:1f18::1:5432 - Local (:::0)"], - }, - { exitCode: 0, stdout: ["type RetriedViaPooler struct {}"] }, - ]); - const poolerConn: LegacyPgConnInput = { - host: "127.0.0.1", - port, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }; - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some(poolerConn), - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); - expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); - expect(child.spawned).toHaveLength(2); - expect(dbConfig.poolerFallbacks).toHaveLength(1); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(out.messages.filter((message) => message.type === "warn")).toEqual([]); + }); + }); - 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: 1, stderr: ["permission denied for schema public"] }, - ]); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "127.0.0.1", - port, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); + it.live("warns that a pre-path --network-id is unused and still generates", () => { + const dbUrl = "postgresql://postgres:postgres@db:5432/postgres"; + const { layer, generator, out } = setup({ + args: ["--network-id", "mycompose_default", "gen", "types", "--db-url", dbUrl], + }); - const exit = await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(1); - expect(dbConfig.poolerFallbacks).toHaveLength(0); + expect(generator.calls).toHaveLength(1); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("docker run --rm --network mycompose_default"), }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ); + }); + }); - 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: 1, - stderr: [ - `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, + 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", LEGACY_VALID_REF], + generatorOutput: scenario.output, + dbConfigResolve: (input) => + Effect.succeed( + remoteResolvedConfig( { - exitCode: 1, - stderr: [ - `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - ]); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ host: "127.0.0.1", - port, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", + port: 5432, + user: `cli_login_${LEGACY_VALID_REF}`, + password: "temporary-password", database: "postgres", - }), - }); - - const exit = await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + }, + (input.linkedProjectRef !== undefined + ? Option.getOrUndefined(input.linkedProjectRef) + : undefined) ?? LEGACY_VALID_REF, + ), + ), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.fail(new Error(`unexpected preview branch lookup for ${branch_id_or_ref}`)), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + }); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(2); - expect(dbConfig.poolerFallbacks).toHaveLength(1); + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: scenario.lang, }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); - 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: 1, - stderr: [ - `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - ]); - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - 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.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 5432, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); - - const exit = yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(1); - expect(dbConfig.poolerFallbacks).toHaveLength(0); - expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); - }), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: LEGACY_VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "getABranchConfig" }), + ); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "generateTypescriptTypes" }), + ); + expect(out.stderrText).toContain("Connecting to 127.0.0.1 5432"); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + // --project-id is an ad-hoc remote ref: the resolver must not inherit + // the workdir's ambient password / saved pooler URL. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); + const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; + expect( + linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, + ).toBe(LEGACY_VALID_REF); + expect(generator.calls).toHaveLength(1); + expect(generator.calls[0]?.conn).toEqual({ + host: "127.0.0.1", + port: 5432, + user: `cli_login_${LEGACY_VALID_REF}`, + password: "temporary-password", + database: "postgres", + }); + expect(generator.calls[0]?.isLocal).toBe(false); + expect(generator.calls[0]?.lang).toBe(scenario.lang); + expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); + expect(out.stdoutText).toBe(`${scenario.output}\n`); + expect(linkedProjectCache.cached).toBe(true); + }); + }); + } - 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, stdout: ["type RetriedAfterProbeFailure struct {}"] }, - ]); - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => - Effect.gen(function* () { - probeCalls += 1; - if (probeCalls === 1) { - return yield* Effect.fail( - new LegacyPgDeltaSslProbeError({ - message: "network is unreachable", - cause: Object.assign(new Error(), { code: "ENETUNREACH" }), - }), - ); - } - return false; - }), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_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.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); + 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(LEGACY_VALID_REF), + generatorOutput: "type PublicMovies struct {}", + }); - yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)); - expect(out.stdoutText).toContain("type RetriedAfterProbeFailure struct {}"); - expect(probeCalls).toBe(2); - expect(child.spawned).toHaveLength(1); - expect(dbConfig.poolerFallbacks).toHaveLength(1); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + // --linked is the workdir's own project: keep workdir-scoped credentials. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + }); + }); + + it.live("preserves resolver URL options for remote non-TypeScript typegen", () => { + const { layer, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + generatorOutput: "type PublicMovies struct {}", + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + options: `reference=${LEGACY_VALID_REF}`, }), ), - 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", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => - Effect.fail( - new LegacyPgDeltaSslProbeError({ - message: "connection refused", - cause: Object.assign(new Error(), { code: "ECONNREFUSED" }), - }), - ), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_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.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); - const exit = yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit); + // Supavisor pooler URLs carry the tenant in `options=reference=`; + // the resolved connection must reach the driver intact. + expect(generator.calls[0]?.conn.options).toBe(`reference=${LEGACY_VALID_REF}`); + expect(generator.calls[0]?.conn.user).toBe(`postgres.${LEGACY_VALID_REF}`); + }); + }); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(0); - expect(dbConfig.poolerFallbacks).toHaveLength(0); + it.live("retries remote generation through the IPv4 pooler on an IPv6 connect failure", () => { + const poolerConn: LegacyPgConnInput = { + host: "127.0.0.1", + port: 6543, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }; + const { layer, out, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + generatorResults: [ + Effect.fail(IPV6_CONNECT_FAILURE), + Effect.succeed("type RetriedViaPooler struct {}"), + ], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", }), ), - 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: 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", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallbackFails: true, - }); - - const exit = await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + poolerFallback: Option.some(poolerConn), + }); - 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(1); + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_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 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 = "${LEGACY_VALID_REF}"`, - "", - "[remotes.staging.api]", - 'schemas = ["private"]', - "", - ].join("\n"), - ); - const docker = captureDockerRun(); - const { layer } = setup({ - workdir, - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_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( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } + 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.${LEGACY_VALID_REF}.supabase.co`); + expect(generator.calls[1]?.conn).toEqual(poolerConn); + expect(generator.calls[1]?.isLocal).toBe(false); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + expect(dbConfig.poolerFallbacks[0]?.connType).toBe("linked"); + expect(dbConfig.poolerFallbacks[0]?.adHocProjectRef).toBe(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("retries remote generation through the IPv4 pooler on a Node ENETUNREACH failure", () => { + const poolerConn: LegacyPgConnInput = { + host: "127.0.0.1", + port: 6543, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }; + const { layer, out, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + generatorResults: [ + Effect.fail( + new LegacyDbConnectError({ + message: + "failed to connect to postgres: connect ENETUNREACH 2600:1f18::1:5432 - Local (:::0)", + }), + ), + Effect.succeed("type RetriedViaPooler struct {}"), + ], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some(poolerConn), + }); + + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_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 = "${LEGACY_VALID_REF}"`, - "", - "[remotes.staging.api]", - 'schemas = ["private"]', - "", - ].join("\n"), - ); - const docker = captureDockerRun(); - const { layer } = setup({ - workdir, + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(generator.calls).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }); + }); + + it.live( + "retries remote generation through the IPv4 pooler on a native ENOTFOUND connect error", + () => { + const poolerConn: LegacyPgConnInput = { + host: "127.0.0.1", + port: 6543, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }; + const { layer, out, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + generatorResults: [ + Effect.fail(NATIVE_ENOTFOUND_CONNECT_FAILURE), + Effect.succeed("type RetriedViaPooler struct {}"), + ], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some(poolerConn), + }); + + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ projectId: Option.some(LEGACY_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( - legacyGenTypes( - defaultFlags({ - linked: true, - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } + lang: "go", + }), + ).pipe(Effect.provide(layer)); - 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); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(generator.calls).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }); + }, ); - it.live("falls back to preview branch config for non-TypeScript project refs", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, api, dbConfig } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], - childStdout: ["class PublicMovies(BaseModel):"], - getProject: () => - Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.succeed({ - ref: branch_id_or_ref, - postgres_version: "15.1", - postgres_engine: "15", - release_channel: "ga", - status: "ACTIVE_HEALTHY", - db_host: "127.0.0.1", - db_port: port, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - createLoginRole: ({ ref }) => - Effect.fail(new Error(`unexpected login role creation for ${ref}`)), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)), - ); + it.live("does not retry remote generation when the failure is not IPv6", () => { + const { layer, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + generatorResults: [ + Effect.fail( + new LegacyGenTypesMetadataError({ + message: "failed to introspect database: permission denied for schema public", + }), + ), + ], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "127.0.0.1", + port: 6543, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); - expect(api.requests).toContainEqual({ - method: "getProject", - input: { ref: LEGACY_VALID_REF }, - }); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: LEGACY_VALID_REF }, - }); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "createLoginRole" }), - ); - expect(dbConfig.resolves).toHaveLength(0); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer), Effect.exit); - 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: 1, - stderr: [ - `could not translate host name "db.${LEGACY_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", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - 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* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)); - - expect(api.requests).toContainEqual({ - method: "getPoolerConfig", - input: { ref: LEGACY_VALID_REF }, - }); - expect(child.spawned).toHaveLength(2); - expect( - dockerEnv(child.spawned[1]?.args ?? []).has( - `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:branch-password@${poolerHost}:5432/postgres?connect_timeout=10`, - ), - ).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + }); + }); + + it.live("does not run pooler fallback a second time when the retry also fails with IPv6", () => { + const { layer, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + generatorResults: [Effect.fail(IPV6_CONNECT_FAILURE), Effect.fail(IPV6_CONNECT_FAILURE)], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", }), ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + poolerFallback: Option.some({ + host: "127.0.0.1", + port: 6543, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }); + }); + + it.live( + "does not retry remote generation when the resolved connection is already a pooler host", + () => { + const { layer, out, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + generatorResults: [Effect.fail(IPV6_CONNECT_FAILURE)], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_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("skips preview branch pooler fallback when the pooler URL fails validation", () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - ]); - const { layer, api } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - 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* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - expect(api.requests).toContainEqual({ - method: "getPoolerConfig", - input: { ref: LEGACY_VALID_REF }, - }); - expect(child.spawned).toHaveLength(1); + it.live("preserves the original generation error when pooler fallback resolution fails", () => { + const { layer, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + generatorResults: [Effect.fail(IPV6_CONNECT_FAILURE)], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", }), ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + poolerFallbackFails: true, + }); - it.live("falls back to preview branch config for any project 404 body", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, api, dbConfig } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], - childStdout: ["class PublicMovies(BaseModel):"], - // The Management API's 404 wording is not guaranteed; a generic body - // must still route to the branch config endpoint. - getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.succeed({ - ref: branch_id_or_ref, - postgres_version: "15.1", - postgres_engine: "15", - release_channel: "ga", - status: "ACTIVE_HEALTHY", - db_host: "127.0.0.1", - db_port: port, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: LEGACY_VALID_REF }, - }); - expect(dbConfig.resolves).toHaveLength(0); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("could not translate host name"); + expect(String(exit.cause)).not.toContain("pooler fallback failed"); + } + expect(generator.calls).toHaveLength(1); + }); + }); + + 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 = "${LEGACY_VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const { layer, generator } = setup({ + workdir, + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + generatorOutput: "type PrivateMovies struct {}", + }); + + return Effect.gen(function* () { + try { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + + 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 = "${LEGACY_VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const { layer, generator } = setup({ + workdir, + projectId: Option.some(LEGACY_VALID_REF), + args: ["gen", "types", "--lang", "go", "--linked"], + generatorOutput: "type PrivateMovies struct {}", + }); + + return Effect.gen(function* () { + try { + yield* legacyGenTypes( + defaultFlags({ + linked: true, + lang: "go", + }), + ).pipe(Effect.provide(layer)); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } + + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private"]); + }); + }); + + 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", LEGACY_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}`)), + }); + + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: LEGACY_VALID_REF }, + }); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: LEGACY_VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(dbConfig.resolves).toHaveLength(0); + expect(generator.calls[0]?.conn).toEqual({ + host: "127.0.0.1", + port: 5432, + user: "branch_user", + password: "branch-password", + database: "postgres", + }); + expect(generator.calls[0]?.isLocal).toBe(false); + }); + }); + + it.live("retries preview branch generation through the branch IPv4 pooler", () => { + const poolerHost = "aws-0-us-east-1.pooler.supabase.com"; + const { layer, api, generator } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + generatorResults: [ + Effect.fail(IPV6_CONNECT_FAILURE), + Effect.succeed("class RetriedViaBranchPooler(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: `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", + }, + ]), + }); + + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: LEGACY_VALID_REF }, + }); + expect(generator.calls).toHaveLength(2); + expect(generator.calls[1]?.conn.host).toBe(poolerHost); + expect(generator.calls[1]?.conn.user).toBe(`postgres.${LEGACY_VALID_REF}`); + // The branch credentials replace the pooler URL's placeholder password. + expect(generator.calls[1]?.conn.password).toBe("branch-password"); + }); + }); + + it.live("skips preview branch pooler fallback when the pooler URL fails validation", () => { + const { layer, api, generator } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + generatorResults: [Effect.fail(IPV6_CONNECT_FAILURE)], + 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", + }, + ]), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: LEGACY_VALID_REF }, + }); + expect(generator.calls).toHaveLength(1); + }); + }); + + 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", LEGACY_VALID_REF], + generatorOutput: "class PublicMovies(BaseModel):", + // The Management API's 404 wording is not guaranteed; a generic body + // must still route to the branch config endpoint. + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + }); + + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: LEGACY_VALID_REF }, + }); + expect(dbConfig.resolves).toHaveLength(0); + expect(generator.calls[0]?.conn.user).toBe("branch_user"); + 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({ @@ -2304,6 +1802,152 @@ describe("legacy gen types", () => { }); }); + it.live("surfaces a non-404 project lookup failure for non-TypeScript generation", () => { + const { layer, dbConfig, generator } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + getProject: () => Effect.fail(statusApiError(500, `{"message":"boom"}`)), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Only a 404 routes to the preview-branch fallback; any other status + // surfaces as the mapped project database config error. + expect(String(exit.cause)).toContain("unexpected project database config status 500"); + } + expect(dbConfig.resolves).toHaveLength(0); + expect(generator.calls).toHaveLength(0); + }); + }); + + it.live("maps project lookup network failures for non-TypeScript generation", () => { + const { layer } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + getProject: () => Effect.fail(new Error("network error")), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_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("failed to get project database config"); + } + }); + }); + + it.live("maps preview branch config network failures after the project 404", () => { + const { layer } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: () => Effect.fail(new Error("network error")), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("failed to get preview branch database config"); + } + }); + }); + + it.live("maps preview branch config status failures after the project 404", () => { + const { layer, generator } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: () => Effect.fail(statusApiError(500, `{"message":"boom"}`)), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "unexpected preview branch database config status 500", + ); + } + expect(generator.calls).toHaveLength(0); + }); + }); + + it.live("skips preview branch pooler fallback when no primary pooler is configured", () => { + const { layer, generator } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + generatorResults: [Effect.fail(IPV6_CONNECT_FAILURE)], + 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: () => Effect.succeed([]), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(generator.calls).toHaveLength(1); + }); + }); + + it.live("maps project type generation status failures", () => { + const { layer } = setup({ + generateTypescriptTypes: () => Effect.fail(statusApiError(500, "generation broke")), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_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 retrieve generated types"); + } + }); + }); + it.live("maps project type generation network failures", () => { const { layer } = setup({ generateTypescriptTypes: () => Effect.fail(new Error("network error")), @@ -2325,332 +1969,230 @@ describe("legacy gen types", () => { }); }); - it.live("spawns pg-meta for local generation and forwards child output", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - 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"), - ); + it.live("generates locally through the native generator", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + "port = 54321", + 'schemas = ["public", "custom"]', + "", + "[db]", + "port = 54322", + ].join("\n"), + ); - const { layer, out, child, linkedProjectCache } = setup({ - workdir, - childStdout: ["export type Database = {};"], - childStderr: ["pg-meta warning"], - onSpawn: docker.onSpawn, - }); + const { layer, out, child, linkedProjectCache, generator, dbConfig } = setup({ + workdir, + generatorOutput: "export type Database = {};", + }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - 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(2); - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", "supabase_db_demo"], - }); - expect(child.spawned[1]?.command).toBe("docker"); - expect(child.spawned[1]?.args).toContain("--network"); - expect(child.spawned[1]?.args).toContain("supabase_network_demo"); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,custom")).toBe( - true, - ); - expect(child.spawned[1]?.args).toContain(resolvePgmetaImage()); - expect(child.spawned[1]?.args.slice(-2)).toEqual(["node", "dist/server/server.js"]); - // The local/db-url paths have no project ref, so they must not - // populate the linked-project cache. - expect(linkedProjectCache.cached).toBe(false); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(out.stderrText).toContain("Connecting to 127.0.0.1 54322"); + expect(out.stdoutText).toBe("export type Database = {};\n"); + // The only remaining subprocess is the local-stack `container inspect`; + // generation itself is in-process. + expect(child.spawned).toEqual([ + { command: "docker", args: ["container", "inspect", "supabase_db_demo"] }, + ]); + expect(generator.calls).toHaveLength(1); + expect(generator.calls[0]?.conn).toEqual({ + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }); + expect(generator.calls[0]?.isLocal).toBe(true); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "custom"]); + // The local path never consults the DB config resolver. + expect(dbConfig.resolves).toHaveLength(0); + // The local/db-url paths have no project ref, so they must not + // populate the linked-project cache. + expect(linkedProjectCache.cached).toBe(false); + }); + }); + + it.live("prints already-terminated native output with exactly one trailing newline", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-newline-")); + writeConfig( + workdir, + ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54322"].join( + "\n", + ), + ); - 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, stdout: ["export type Database = {};"] }, - ]); - const { layer, out } = setup({ - workdir, - childLayer: child.layer, - }); - - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + const { layer, out } = setup({ + workdir, + generatorOutput: "export type Database = {};\n\n", + }); - 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]?.command).toBe("docker"); - expect(child.spawned[2]?.args).toContain("run"); - expect(child.spawned[3]?.command).toBe("podman"); - expect(child.spawned[3]?.args).toContain("run"); - expect(child.spawned[3]?.args).toContain("supabase_network_demo"); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).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(out.stdoutText).toBe("export type Database = {};\n"); + }); + }); - const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; - process.env["SUPABASE_DB_PASSWORD"] = "secret-password"; - try { - const { layer, child } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).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 = 54322"].join( + "\n", + ), + ); + const child = mockDockerMissingChildProcessSpawner([{ exitCode: 0 }]); + const { layer, out } = setup({ + workdir, + childLayer: child.layer, + generatorOutput: "export type Database = {};", + }); - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", "supabase_db_demo_project_with_spaces"], - }); - expect(child.spawned[1]?.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); - } finally { - if (previousPassword === undefined) { - delete process.env["SUPABASE_DB_PASSWORD"]; - } else { - process.env["SUPABASE_DB_PASSWORD"] = previousPassword; - } - } - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - 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(out.stdoutText).toContain("export type Database = {};"); + expect(child.spawned).toEqual([ + { command: "docker", args: ["container", "inspect", "supabase_db_demo"] }, + { command: "podman", args: ["container", "inspect", "supabase_db_demo"] }, + ]); + }); + }); - const { layer } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + 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 = 54322", + ].join("\n"), + ); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; + process.env["SUPABASE_DB_PASSWORD"] = "secret-password"; + const { layer, child, generator } = setup({ workdir }); - 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* () { + try { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - 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"); + expect(child.spawned[0]).toEqual({ + command: "docker", + args: ["container", "inspect", "supabase_db_demo_project_with_spaces"], + }); + expect(generator.calls[0]?.conn.password).toBe("secret-password"); + } finally { + if (previousPassword === undefined) { + delete process.env["SUPABASE_DB_PASSWORD"]; + } else { + process.env["SUPABASE_DB_PASSWORD"] = previousPassword; + } + } + }); + }); - const { layer } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + 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 = 54322", + ].join("\n"), + ); + writeTempFile(workdir, "rest-version", "v9.0.1\n"); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + const { layer, generator } = setup({ workdir }); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=true"), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - 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"); + expect(generator.calls[0]?.postgrestV9Compat).toBe(true); + }); + }); - const { layer, child } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + 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 = 54322", + ].join("\n"), + ); + writeTempFile(workdir, "rest-version", "v9.0.1\n"); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + const { layer, generator } = setup({ workdir }); - expect(child.spawned[1]?.args).toContain(resolvePgmetaImage("0.99.0")); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).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]?.postgrestV9Compat).toBe(false); + }); + }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).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 = 54322", + ].join("\n"), + ); + const { layer, generator } = setup({ workdir }); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=auth,storage")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).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]?.includedSchemas).toEqual(["auth", "storage"]); + }); + }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + 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 = 54322"].join("\n")); + const { layer, child } = setup({ workdir }); - const inspectId = child.spawned[0]?.args[2] ?? ""; - expect(inspectId.startsWith("supabase_db_")).toBe(true); - expect(inspectId).not.toBe("supabase_db_demo"); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + const inspectId = child.spawned[0]?.args[2] ?? ""; + expect(inspectId.startsWith("supabase_db_")).toBe(true); + expect(inspectId).not.toBe("supabase_db_demo"); + }); + }); it.live("generates from --project-id without a local project config", () => { const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-pid-no-config-")); @@ -2687,22 +2229,6 @@ describe("legacy gen types", () => { }); }); - it.live("ignores positional language scanning when argv lacks the gen types context", () => { - const { layer, api } = setup({ - args: ["unrelated", "argv"], - projectId: Option.some(LEGACY_VALID_REF), - projectTypes: "ok", - }); - - return Effect.gen(function* () { - yield* legacyGenTypes(defaultFlags({ projectId: Option.some(LEGACY_VALID_REF) })).pipe( - Effect.provide(layer), - ); - - expect(api.requests).toHaveLength(1); - }); - }); - it.live("rejects a non-typescript language passed after a -- separator", () => { const { layer } = setup({ args: ["gen", "types", "--", "go"] }); @@ -2741,18 +2267,6 @@ describe("legacy gen types", () => { }); }); - it.live("treats a positional after a valueless short flag as the language", () => { - const { layer } = setup({ args: ["gen", "types", "-x", "go"] }); - - return Effect.gen(function* () { - const exit = yield* legacyGenTypes(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("prefers explicit --schema on the linked path", () => { const { layer, api } = setup({ projectId: Option.some(LEGACY_VALID_REF), @@ -2888,21 +2402,10 @@ describe("legacy 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({ + const { layer, out, child, generator } = setup({ workdir, skipConfig: true, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: (host, port) => - Effect.sync(() => { - probes.push({ host, port }); - return false; - }), - }), + generatorOutput: "generated", }); return Effect.gen(function* () { @@ -2913,11 +2416,9 @@ describe("legacy gen types", () => { command: "docker", args: ["container", "inspect", localDbContainerId(projectId)], }); - expect(child.spawned[1]?.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]?.conn.host).toBe("127.0.0.1"); + expect(generator.calls[0]?.conn.port).toBe(54322); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "graphql_public"]); expect(out.stdoutText).toContain("generated"); }); }); @@ -2934,25 +2435,13 @@ describe("legacy gen types", () => { "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({ + const { layer, out, child, generator } = setup({ workdir, skipConfig: true, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: (host, port) => - Effect.sync(() => { - probes.push({ host, port }); - return false; - }), - }), + generatorOutput: "generated", }); return Effect.gen(function* () { @@ -2962,21 +2451,12 @@ describe("legacy gen types", () => { command: "docker", args: ["container", "inspect", localDbContainerId("configless-env-project")], }); - expect(child.spawned[1]?.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[1]?.args.some((arg) => - arg.startsWith("mirror.example.com/supabase/postgres-meta:"), - ), - ).toBe(true); + expect(generator.calls[0]?.conn.host).toBe("host.docker.internal"); + expect(generator.calls[0]?.conn.port).toBe(55432); + // SUPABASE_DB_PASSWORD is deliberately excluded when applying project + // env, so the local connection keeps the default password. + expect(generator.calls[0]?.conn.password).toBe("postgres"); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private", "graphql_public"]); expect(out.stdoutText).toContain("generated"); }); }); @@ -3005,265 +2485,118 @@ describe("legacy 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( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - }), - ).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("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: 1, stderr: ["pg-meta failed"] }, - ]); - const { layer } = setup({ - workdir, - childLayer: sequence.layer, - }); - - const exit = await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).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(sequence.spawned).toHaveLength(2); - }), - 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( - legacyGenTypes( - 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)), - ); - - expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); - expect(child.spawned[0]?.args).toContain("--network"); - expect(child.spawned[0]?.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("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( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + it.live("defaults schemas to public for a db-url run without a project config", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-dburl-no-config-")); + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const { layer, dbConfig, generator } = setup({ + workdir, + skipConfig: true, + }); - expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(true); - }, "S"), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); - // The SSL probe does not special-case `--debug`: a successful probe - // returns true regardless, so the bundle is passed to pgmeta regardless of - // the flag. - 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( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("db-url"); + expect( + dbConfig.resolves[0] !== undefined + ? Option.getOrUndefined(dbConfig.resolves[0].dbUrl) + : undefined, + ).toBe(dbUrl); + expect(generator.calls[0]?.includedSchemas).toEqual(["public"]); + }); + }); - 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("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 = 54322"].join( + "\n", + ), + ); + const { layer, child } = setup({ + workdir, + generatorResults: [ + Effect.fail( + new LegacyGenTypesMetadataError({ + message: "failed to introspect database: relation does not exist", + }), + ), + ], + }); - it.live("warns on stderr when SUPABASE_CA_SKIP_VERIFY is enabled", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const previous = process.env["SUPABASE_CA_SKIP_VERIFY"]; - process.env["SUPABASE_CA_SKIP_VERIFY"] = "true"; - try { - const { layer, out } = setup({ childStdout: ["generated"] }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); - expect(out.stderrText).toContain( - "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)", - ); - } finally { - if (previous === undefined) { - delete process.env["SUPABASE_CA_SKIP_VERIFY"]; - } else { - process.env["SUPABASE_CA_SKIP_VERIFY"] = previous; - } - } - }), - 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( + "failed to introspect database: relation does not exist", + ); + } + expect(child.spawned).toHaveLength(1); + }); + }); - 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( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + it.live("runs the native generator for db-url generation", () => { + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const { layer, out, dbConfig, generator, linkedProjectCache } = setup({ + generatorOutput: "generated", + }); - expect(child.spawned[0]?.args).toContain("custom-network"); - expect(child.spawned[0]?.args).not.toContain("host"); + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(dbUrl), + lang: "swift", + schema: ["public"], + swiftAccessControl: "public", + postgrestV9Compat: true, + queryTimeout: "20s", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); - 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( - legacyGenTypes( - 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)), - ); + expect(out.stderrText).toContain("Connecting to 127.0.0.1 5432"); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("db-url"); + expect(generator.calls[0]?.lang).toBe("swift"); + expect(generator.calls[0]?.swiftAccessControl).toBe("public"); + expect(generator.calls[0]?.postgrestV9Compat).toBe(true); + expect(generator.calls[0]?.queryTimeoutSeconds).toBe(20); + expect(generator.calls[0]?.isLocal).toBe(false); + expect(out.stdoutText).toBe("generated\n"); + expect(linkedProjectCache.cached).toBe(false); + }); + }); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, - ), - ).toBe(true); + it.live("passes the resolver's local detection through for a local db-url", () => { + const dbUrl = "postgresql://postgres@127.0.0.1:54322/postgres"; + const { layer, generator } = setup({ + dbConfigResolve: () => + Effect.succeed({ + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }, + isLocal: true, }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - - it.live("accepts legacy positional typescript without changing behavior", () => { - const { layer } = setup({ - args: ["gen", "types", "typescript"], - projectId: Option.some(LEGACY_VALID_REF), - projectTypes: "ok", }); return Effect.gen(function* () { - yield* legacyGenTypes(defaultFlags()).pipe(Effect.provide(layer)); + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); + + expect(generator.calls[0]?.isLocal).toBe(true); }); }); @@ -3301,30 +2634,22 @@ describe("legacy gen types", () => { }, ); - 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( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - lang: "go", - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + it.live("allows legacy positional non-typescript when --lang is explicitly set", () => { + const dbUrl = "postgresql://postgres:postgres@127.0.0.1:5432/postgres"; + const { layer, generator } = setup({ + args: ["gen", "types", "go", "--lang", "go"], + }); - expect(docker.env.has("PG_META_GENERATE_TYPES=go")).toBe(true); + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(dbUrl), + lang: "go", + schema: ["public"], }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); + + expect(generator.calls[0]?.lang).toBe("go"); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.layers.ts b/apps/cli/src/legacy/commands/gen/types/types.layers.ts index 70256eddc0..e1dc51d21f 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.layers.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.layers.ts @@ -12,7 +12,6 @@ import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; -import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyIdentityStitch, legacyIdentityStitchLayer, @@ -24,6 +23,8 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { CommandRuntime } from "../../../../shared/runtime/command-runtime.service.ts"; +import { legacyGenTypesGeneratorLayer } from "./types.generator.layer.ts"; +import { LegacyGenTypesGenerator } from "./types.generator.ts"; /** * `gen types --local` and `--db-url` do not use the Management API, so this @@ -66,7 +67,7 @@ export const legacyGenTypesRuntimeLayer = (() => { Layer.provide(httpClient), Layer.provide(legacyIdentityStitchLayer), ), - legacyPgDeltaSslProbeLayer, + legacyGenTypesGeneratorLayer.pipe(Layer.provide(legacyPgDeltaSslProbeLayer)), legacyTelemetryStateLayer, // The one per-command identity stitcher, exposed at top level so // `withLegacyCommandInstrumentation` can read @@ -91,7 +92,7 @@ type LegacyGenTypesServices = | LegacyCliSettings | LegacyProjectRefResolver | LegacyDbConfigResolver - | LegacyPgDeltaSslProbe + | LegacyGenTypesGenerator | LegacyLinkedProjectCache | LegacyTelemetryState | LegacyIdentityStitch diff --git a/apps/cli/src/legacy/commands/gen/types/types.oxfmt.ts b/apps/cli/src/legacy/commands/gen/types/types.oxfmt.ts new file mode 100644 index 0000000000..c74f074a24 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.oxfmt.ts @@ -0,0 +1,155 @@ +/** + * Statically-dispatched oxfmt native binding for TypeScript typegen output. + * + * `@supabase/postgrest-typegen`'s default formatter goes through the `oxfmt` + * JS package, whose ESM dist resolves its platform binding at runtime via + * `createRequire(import.meta.url)` — a dynamic path `bun build --compile` + * cannot follow, so the compiled binary would fail to find the `.node` addon + * (and the same dist lazily imports optional prettier plugins that are not + * installed at all). Instead, mirror the `@parcel/watcher` pattern + * (`shared/runtime/parcel-file-watcher.layer.ts`): one static `require` per + * shipped CLI target, so Bun embeds exactly the right `.node` binding, and + * inject the resulting formatter through the generator's `format` option. + * + * Source-run ESM has no `require` binding (`ReferenceError`); compiled Bun + * injects one that loads embedded `.node` addons. `createRequire` is only + * the source-run fallback — using it in the compiled binary resolves from + * `/$bunfs/root` and misses those addons. Per-platform `require("…")` + * literals stay so `bun build --compile` still embeds them. + * + * The binding version in `package.json` must stay in lockstep with the + * `oxfmt` version pinned by `@supabase/postgrest-typegen`, and the format + * options below must mirror the package's own `defaultFormat` so injected and + * default output stay identical. + */ + +import { createRequire } from "node:module"; + +declare const SUPABASE_LIBC: string | undefined; + +/** + * Callback the binding invokes to format embedded languages (CSS-in-JS + * template literals and similar). Generated type declarations contain no + * template literals, so these can never fire for typegen output. + */ +type LegacyOxfmtEmbedCallback = (options: unknown, code: unknown) => never; + +interface LegacyOxfmtBinding { + readonly format: ( + fileName: string, + sourceText: string, + options: Readonly>, + formatFileCallback: LegacyOxfmtEmbedCallback, + formatEmbeddedCodeCallback: LegacyOxfmtEmbedCallback, + formatEmbeddedDocCallback: LegacyOxfmtEmbedCallback, + ) => Promise<{ + readonly code: string; + readonly errors: ReadonlyArray<{ readonly message: string }>; + }>; +} + +const sourceRequire = createRequire(import.meta.url); + +function loadOxfmtBinding( + loadCompiled: () => LegacyOxfmtBinding, + specifier: string, +): LegacyOxfmtBinding { + try { + return loadCompiled(); + } catch (error) { + if (error instanceof ReferenceError) { + return sourceRequire(specifier); + } + throw error; + } +} + +function legacyRequireOxfmtBinding(): LegacyOxfmtBinding { + 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: LegacyOxfmtEmbedCallback = () => { + 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 (same virtual file name, same + * `semi`/`printWidth` options, same error surfacing). + */ +export async function legacyOxfmtTypegenFormat(code: string): Promise { + const binding = legacyRequireOxfmtBinding(); + 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/legacy/commands/gen/types/types.shared.ts b/apps/cli/src/legacy/commands/gen/types/types.shared.ts index c94d53665f..296abc491f 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -1,152 +1,102 @@ import { Effect } from "effect"; -import { dockerfileServiceImageRaw } from "../../../../shared/services/dockerfile-images.ts"; -import { slimImageForCurrentPin } from "../../../../shared/services/slim-images.ts"; -import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; -import { - LegacyInvalidGenTypesDatabaseUrlError, - LegacyInvalidGenTypesDurationError, -} 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"; -// Local Docker resource ids are hoisted to `legacy/shared` so the declarative seam -// can derive the same `supabase_db_` name when checking the local stack. -export { localDbContainerId, localNetworkId } from "../../../shared/legacy-docker-ids.ts"; +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; +import { LegacyInvalidGenTypesDurationError } from "./types.errors.ts"; -const LEGACY_DEFAULT_CONNECT_TIMEOUT_SECONDS = 10; - -const DURATION_UNITS_TO_MILLIS = { - ns: 1 / 1_000_000, - us: 1 / 1_000, - "\u00b5s": 1 / 1_000, - "\u03bcs": 1 / 1_000, - ms: 1, - s: 1_000, - m: 60_000, - h: 3_600_000, -} as const; - -const DURATION_PART_PATTERN = new RegExp( - String.raw`([+-]?(?:\d+\.?\d*|\.\d+))(ns|us|\u00b5s|\u03bcs|ms|s|m|h)`, - "g", -); - -export interface LegacyGenTypesDbTarget { - readonly url: string; - readonly host: string; - readonly port: number; - readonly networkMode: "host" | (string & {}); -} +// The local Docker container id is hoisted to `legacy/shared` so the declarative +// seam can derive the same `supabase_db_` name when checking the local stack. +export { localDbContainerId } from "../../../shared/legacy-docker-ids.ts"; export function defaultSchemas(extraSchemas: ReadonlyArray = []) { return [...new Set(["public", ...extraSchemas])]; } +function invalidQueryTimeout(raw: string, detail?: string) { + return new LegacyInvalidGenTypesDurationError({ + message: + detail === undefined + ? `invalid duration ${JSON.stringify(raw)}` + : `invalid duration ${JSON.stringify(raw)}: ${detail}`, + }); +} + export function parseQueryTimeoutSeconds( raw: string, ): Effect.Effect { - return Effect.gen(function* () { - const input = raw.trim(); - if (input.length === 0) { - return yield* Effect.fail( - new LegacyInvalidGenTypesDurationError({ - message: `invalid duration ${JSON.stringify(raw)}`, - }), - ); - } - - let totalMillis = 0; - let consumed = 0; - DURATION_PART_PATTERN.lastIndex = 0; - for (const match of input.matchAll(DURATION_PART_PATTERN)) { - const [token, rawNumber, rawUnit] = match; - if ( - token === undefined || - rawNumber === undefined || - rawUnit === undefined || - match.index === undefined - ) { - continue; + return Effect.try({ + try: () => legacyParseGoDuration(raw), + catch: () => invalidQueryTimeout(raw), + }).pipe( + Effect.flatMap((nanos) => { + if (nanos < 0) { + return Effect.fail(invalidQueryTimeout(raw)); } - if (match.index !== consumed) { - return yield* Effect.fail( - new LegacyInvalidGenTypesDurationError({ - message: `invalid duration ${JSON.stringify(raw)}`, - }), - ); + // Whole-second `statement_timeout` / client bound. `0` is the disable + // sentinel — a positive duration that rounds into it would silently + // drop the user's requested cap. + const seconds = Math.round(nanos / 1_000_000_000); + if (seconds === 0 && nanos !== 0) { + return Effect.fail(invalidQueryTimeout(raw, "use 0 to disable, or at least 500ms")); } - const amount = Number.parseFloat(rawNumber); - const unitMillis = DURATION_UNITS_TO_MILLIS[rawUnit as keyof typeof DURATION_UNITS_TO_MILLIS]; - totalMillis += amount * unitMillis; - consumed += token.length; - } - - if (!Number.isFinite(totalMillis) || consumed !== input.length || totalMillis < 0) { - return yield* Effect.fail( - new LegacyInvalidGenTypesDurationError({ - message: `invalid duration ${JSON.stringify(raw)}`, - }), - ); - } - - return Math.round(totalMillis / 1_000); - }); + return Effect.succeed(seconds); + }), + ); } export function localDbPassword() { return process.env["SUPABASE_DB_PASSWORD"] ?? "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 LegacyGenTypesDbTarget; - }, - catch: (cause) => - new LegacyInvalidGenTypesDatabaseUrlError({ - message: `failed to parse connection string: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); +/** + * `--query-timeout` parity with the retired pg-meta envs: the flag becomes + * session `statement_timeout` (milliseconds; `0` disables) and, when the DSN + * has no `connect_timeout` and the flag is positive, the connect timeout. + * Zero must not become `connectTimeoutSeconds: 0` — the driver treats that as + * an immediate `Effect.timeout` rather than "disabled". + */ +export function applyQueryTimeouts( + conn: LegacyPgConnInput, + queryTimeoutSeconds: number, +): LegacyPgConnInput { + const runtimeParams = { + ...conn.runtimeParams, + statement_timeout: `${queryTimeoutSeconds * 1000}`, + }; + if (queryTimeoutSeconds > 0 && conn.connectTimeoutSeconds === undefined) { + return { ...conn, connectTimeoutSeconds: queryTimeoutSeconds, runtimeParams }; + } + return { ...conn, runtimeParams }; } -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=${LEGACY_DEFAULT_CONNECT_TIMEOUT_SECONDS}` - ); -} - -export function resolvePgmetaImage(versionOverride?: string) { - const raw = dockerfileServiceImageRaw("pgmeta"); - const trimmed = versionOverride?.trim() ?? ""; - const pin = trimmed.length > 0 ? `v${trimmed.replace(/^v/i, "")}` : undefined; - return legacyGetRegistryImageUrl(slimImageForCurrentPin("pgmeta", raw, pin)); +/** + * When the DSN omitted `sslmode`, the SSLRequest probe decides: no TLS → + * `disable`; TLS → `require` plus the embedded CA path so the driver promotes + * to `verify-ca` (the retired `PG_META_DB_SSL_ROOT_CERT` injection). + */ +export function applyProbedSslMode( + conn: LegacyPgConnInput, + useTls: boolean, + sslrootcert?: string, +): LegacyPgConnInput { + if (conn.sslmode !== undefined) return conn; + if (!useTls) return { ...conn, sslmode: "disable" }; + return { + ...conn, + sslmode: "require", + ...(sslrootcert !== undefined && sslrootcert.length > 0 ? { sslrootcert } : {}), + }; } -export function legacyRootCaBundle() { - return `${caStaging2021}${caProd2021}${caProd2025}`; +/** + * `--network-id` cannot attach the in-process generator to a Docker network. + * Point at a host-reachable DSN, or run the CLI inside that network. + */ +export function legacyGenTypesNetworkIdUnusedWarning(networkId: string): string { + const network = networkId.length > 0 ? networkId : ""; + return ( + "--network-id is unused: gen types no longer runs inside a container and cannot join a Docker network.\n" + + "To reach a hostname that exists only on that network:\n" + + ` docker run --rm --network ${network} node:lts npx --yes supabase gen types --db-url ` + ); } diff --git a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts index f125292570..06f0bb57cd 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts @@ -1,260 +1,129 @@ +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; -import { dockerfileServiceImageRaw } from "../../../../shared/services/dockerfile-images.ts"; -import { toSlimImage } from "../../../../shared/services/slim-images.ts"; -import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; -import { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; import { - buildPostgresUrl, + applyProbedSslMode, + applyQueryTimeouts, defaultSchemas, - legacyRootCaBundle, - localDbContainerId, - localDbPassword, - localNetworkId, - parseDatabaseUrl, + legacyGenTypesNetworkIdUnusedWarning, parseQueryTimeoutSeconds, - resolvePgmetaImage, } from "./types.shared.ts"; -const currentPgmeta = dockerfileServiceImageRaw("pgmeta"); -const currentPgmetaTag = currentPgmeta.split(":")[1] ?? ""; - -function withEnv(key: string, value: string | undefined, run: () => T): T { - const previous = process.env[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - try { - return run(); - } finally { - if (previous === undefined) { - delete process.env[key]; - } else { - process.env[key] = previous; - } +function expectInvalidDuration(exit: Exit.Exit, raw: string) { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain(`invalid duration ${JSON.stringify(raw)}`); } } +const BASE_CONN = { + host: "db.example.com", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", +}; + describe("parseQueryTimeoutSeconds", () => { - it.effect("parses compound Go durations", () => + it.effect("accepts Go's bare 0 as disable", () => 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* parseQueryTimeoutSeconds("0")).toBe(0); + expect(yield* parseQueryTimeoutSeconds("0s")).toBe(0); + expect(yield* parseQueryTimeoutSeconds("0ms")).toBe(0); }), ); - it.effect("rounds sub-second durations to whole seconds", () => + it.effect("rounds 500ms up to a still-applied 1s bound", () => Effect.gen(function* () { expect(yield* parseQueryTimeoutSeconds("500ms")).toBe(1); - expect(yield* parseQueryTimeoutSeconds("400ms")).toBe(0); - }), - ); - - it.effect("rejects an empty duration", () => - Effect.gen(function* () { - const exit = yield* parseQueryTimeoutSeconds(" ").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); - 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); - 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); - expect(Exit.isFailure(exit)).toBe(true); }), ); - it.effect("rejects a negative duration", () => + it.effect("rejects a positive duration that would disable the timeout", () => Effect.gen(function* () { - const exit = yield* parseQueryTimeoutSeconds("-5s").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); + for (const raw of ["1ms", "400ms", `15${"µ"}s`, `15${"μ"}s`]) { + expectInvalidDuration(yield* parseQueryTimeoutSeconds(raw).pipe(Effect.exit), raw); + } }), ); }); -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, () => resolvePgmetaImage()), - ); - expect(image).toContain("postgres-meta"); - }); - - it("strips a leading v from a version override", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => - resolvePgmetaImage("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", () => - resolvePgmetaImage(" "), - ); - const withoutOverride = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => - resolvePgmetaImage(), +describe("applyQueryTimeouts", () => { + it("writes statement_timeout last so the flag overrides a DSN value", () => { + const conn = applyQueryTimeouts( + { ...BASE_CONN, runtimeParams: { statement_timeout: "0", search_path: "public" } }, + 15, ); - expect(withOverride).toBe(withoutOverride); - }); - - it("uses the supabase registry for any non docker.io registry", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => - resolvePgmetaImage("1.2.3"), - ); - expect(image).not.toBe("supabase/postgres-meta:v1.2.3"); - expect(image).toContain("postgres-meta:v1.2.3"); + expect(conn.runtimeParams).toEqual({ + statement_timeout: "15000", + search_path: "public", + }); + expect(conn.connectTimeoutSeconds).toBe(15); }); - it("defaults to the ECR mirror when no registry override is set", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => - resolvePgmetaImage("1.2.3"), - ); - expect(image).toBe("public.ecr.aws/supabase/postgres-meta:v1.2.3"); + it("leaves connect timeout unset when the query timeout is zero", () => { + const conn = applyQueryTimeouts(BASE_CONN, 0); + expect(conn.connectTimeoutSeconds).toBeUndefined(); + expect(conn.runtimeParams).toEqual({ statement_timeout: "0" }); }); - it("honors SUPABASE_INTERNAL_IMAGE_REGISTRY for a non docker.io registry (e.g. ghcr.io)", () => { - // Regression: setup-cli exports `ghcr.io` on shared CI runners to dodge ECR - // rate limits, but gen types used to ignore it and still pull from ECR. - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "ghcr.io", () => - resolvePgmetaImage("1.2.3"), - ); - expect(image).toBe("ghcr.io/supabase/postgres-meta:v1.2.3"); + it("keeps an explicit DSN connect_timeout", () => { + const conn = applyQueryTimeouts({ ...BASE_CONN, connectTimeoutSeconds: 30 }, 15); + expect(conn.connectTimeoutSeconds).toBe(30); }); +}); - it("rewrites to an arbitrary configured mirror registry", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "my.registry.example", () => - resolvePgmetaImage("1.2.3"), - ); - expect(image).toBe("my.registry.example/supabase/postgres-meta:v1.2.3"); +describe("applyProbedSslMode", () => { + it("disables TLS when the probe reports a plain-TCP server", () => { + expect(applyProbedSslMode(BASE_CONN, false).sslmode).toBe("disable"); }); - it("slim-translates the current pin and skips registry rewrite", () => { - const image = withEnv("SUPABASE_USE_SLIM_IMAGES", "1", () => - withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => - resolvePgmetaImage(currentPgmetaTag), - ), - ); - expect(image).toBe(toSlimImage("pgmeta", currentPgmeta)); + it("pins require plus the CA path when the probe reports TLS", () => { + expect(applyProbedSslMode(BASE_CONN, true, "/tmp/root.crt")).toMatchObject({ + sslmode: "require", + sslrootcert: "/tmp/root.crt", + }); }); - 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", () => resolvePgmetaImage("1.2.3")), - ); - expect(image).toBe("supabase/postgres-meta:v1.2.3"); + it("leaves an explicit sslmode unchanged", () => { + const conn = { ...BASE_CONN, sslmode: "verify-full" }; + expect(applyProbedSslMode(conn, true, "/tmp/root.crt")).toBe(conn); }); }); -describe("schema and id helpers", () => { - it("normalizes comma separated and repeated schema flags", () => { - // pflag's StringSlice parses each value via encoding/csv with NO - // trimming, and an empty value yields no field. Whitespace is preserved - // verbatim. - expect(legacyParseSchemaFlags(["public, auth", " storage ", ""])).toEqual([ - "public", - " auth", - " storage ", - ]); - }); - +describe("schema helpers", () => { it("prepends public and removes duplicates from default schemas", () => { expect(defaultSchemas(["auth", "public", "storage"])).toEqual(["public", "auth", "storage"]); expect(defaultSchemas()).toEqual(["public"]); }); +}); - it("derives sanitized docker ids from the project id", () => { - expect(localDbContainerId("..my project")).toBe("supabase_db_my_project"); - expect(localNetworkId("..my project")).toBe("supabase_network_my_project"); - }); - - it("truncates an over-long project id to 40 characters", () => { - const longId = "a".repeat(60); - expect(localDbContainerId(longId)).toBe(`supabase_db_${"a".repeat(40)}`); +describe("legacyGenTypesNetworkIdUnusedWarning", () => { + it("uses a placeholder when the flag value is empty", () => { + expect(legacyGenTypesNetworkIdUnusedWarning("")).toContain("--network "); }); +}); - it("reads the services hostname and db password from the environment", () => { - expect( - withEnv("DOCKER_HOST", undefined, () => - withEnv("SUPABASE_SERVICES_HOSTNAME", undefined, () => legacyGetHostname()), - ), - ).toBe("127.0.0.1"); - expect(withEnv("SUPABASE_SERVICES_HOSTNAME", "db.internal", () => legacyGetHostname())).toBe( - "db.internal", +describe("oxfmt binding pin", () => { + it("stays on the oxfmt version postgrest-typegen resolves", () => { + const cliPackageJson = fileURLToPath(new URL("../../../../../package.json", import.meta.url)); + const cliPkg = JSON.parse(readFileSync(cliPackageJson, "utf8")) as { + readonly devDependencies: Readonly>; + }; + const typegenReq = createRequire(cliPackageJson); + const typegenPkg = typegenReq("@supabase/postgrest-typegen/package.json") as { + readonly dependencies: Readonly>; + }; + const oxfmtVersion = typegenPkg.dependencies["oxfmt"]; + expect(oxfmtVersion).toEqual(expect.stringMatching(/^\d+\.\d+\.\d+/)); + const bindingPins = Object.entries(cliPkg.devDependencies).filter(([name]) => + name.startsWith("@oxfmt/binding-"), ); - expect(withEnv("SUPABASE_DB_PASSWORD", undefined, () => localDbPassword())).toBe("postgres"); - expect(withEnv("SUPABASE_DB_PASSWORD", "secret", () => localDbPassword())).toBe("secret"); - }); - - 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(legacyRootCaBundle().length).toBeGreaterThan(0); + expect(bindingPins.length).toBeGreaterThan(0); + for (const [, version] of bindingPins) { + expect(version).toBe(oxfmtVersion); + } }); }); diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 80111ef1e0..54511190a0 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -31,10 +31,13 @@ export function legacyIpv6Suggestion(): string { // `ipv6LiteralPattern`: an IPv6 address in brackets // (Go dial form) or parens (libpq form). Run against the original-case message. const IPV6_LITERAL_PATTERN = /(?:\[[0-9a-fA-F:]+\]|\([0-9a-fA-F:]+\))/; -// Node's dial-failure shape (`connect ENETUNREACH 2600:…:5432`). The port may be -// followed by whitespace, end-of-string, or a closing paren — the connect-failure -// message renders the driver cause parenthesized (pgconn `dial error (…)` form). -const NODE_ENETUNREACH_PATTERN = /\benetunreach\s+([0-9a-fA-F:]+):\d+(?:[\s)]|$)/i; +// Node's dial-failure shape (`connect EHOSTUNREACH 2600:…:5432`). The port may +// be followed by whitespace, end-of-string, or a closing paren — the +// connect-failure message renders the driver cause parenthesized (pgconn +// `dial error (…)` form). ENETUNREACH / EHOSTUNREACH / EADDRNOTAVAIL are the +// IPv6-unreachable errnos; LegacyDbConnectError keeps only this rendered text. +const NODE_IPV6_DIAL_PATTERN = + /\b(?:enetunreach|ehostunreach|eaddrnotavail)\s+([0-9a-fA-F:]+):\d+(?:[\s)]|$)/i; /** * Port of `isIPv6ConnectivityError`. Lower-cases the @@ -48,8 +51,8 @@ export function legacyIsIPv6ConnectivityError(message: string): boolean { if (lower.includes("address family for hostname not supported")) return true; if (lower.includes("no address associated with hostname")) return true; if (lower.includes("network is unreachable")) return true; - const nodeEnetunreachMatch = NODE_ENETUNREACH_PATTERN.exec(message); - if (nodeEnetunreachMatch?.[1] !== undefined) return isIPv6(nodeEnetunreachMatch[1]); + const nodeIpv6DialMatch = NODE_IPV6_DIAL_PATTERN.exec(message); + if (nodeIpv6DialMatch?.[1] !== undefined) return isIPv6(nodeIpv6DialMatch[1]); if (lower.includes("no route to host") || lower.includes("cannot assign requested address")) { return IPV6_LITERAL_PATTERN.test(message); } @@ -455,6 +458,10 @@ function hasStringCode(error: unknown): error is { * Used by the container-level pooler fallback (`gen types` / `db dump`); the * connect-suggestion path uses the narrower `legacyHasIPv6DialCause` instead, * which must not treat a DNS miss as IPv6. + * + * Native connect errors drop `code`/`cause` and render + * `hostname resolving error (getaddrinfo ENOTFOUND)` — the message fallback + * must recognize that shape. Do not add it to `legacyIsIPv6ConnectivityError`. */ export function legacyIsIPv6ConnectivityErrorCause(error: unknown): boolean { if (error instanceof AggregateError) { @@ -487,5 +494,8 @@ export function legacyIsIPv6ConnectivityErrorCause(error: unknown): boolean { } } - return legacyIsIPv6ConnectivityError(error instanceof Error ? error.message : String(error)); + const text = error instanceof Error ? error.message : String(error); + if (legacyIsIPv6ConnectivityError(text)) return true; + const lower = text.toLowerCase(); + return lower.includes("hostname resolving error") && lower.includes("enotfound"); } diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts index 539ba6c077..fac5335864 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts @@ -85,9 +85,31 @@ describe("legacyIsIPv6ConnectivityError", () => { ).toBe(true); }); + it("classifies Node EHOSTUNREACH and EADDRNOTAVAIL stderr for IPv6 literals", () => { + expect(legacyIsIPv6ConnectivityError("connect EHOSTUNREACH 2600:1f18::1:5432")).toBe(true); + expect(legacyIsIPv6ConnectivityError("connect EADDRNOTAVAIL 2a05:d014::1:5432")).toBe(true); + expect(legacyIsIPv6ConnectivityError("connect EHOSTUNREACH 10.0.0.1:5432")).toBe(false); + expect(legacyIsIPv6ConnectivityError("connect EADDRNOTAVAIL 10.0.0.1:5432")).toBe(false); + }); + + it("classifies Node EHOSTUNREACH inside the parenthesized connect-failure rendering", () => { + expect( + legacyIsIPv6ConnectivityError( + "failed to connect to `host=db.x.supabase.co user=postgres database=postgres`: dial error (connect EHOSTUNREACH 2600:1f18::1:5432)", + ), + ).toBe(true); + }); + it("does not classify unrelated errors", () => { expect(legacyIsIPv6ConnectivityError("permission denied for schema public")).toBe(false); expect(legacyIsIPv6ConnectivityError("")).toBe(false); + // Suggestion path must not treat a DNS miss as IPv6; pooler fallback uses + // `legacyIsIPv6ConnectivityErrorCause` for the native rendered shape. + expect( + legacyIsIPv6ConnectivityError( + "failed to connect to `host=db.x.supabase.co user=postgres database=postgres`: hostname resolving error (getaddrinfo ENOTFOUND)", + ), + ).toBe(false); }); }); @@ -593,4 +615,38 @@ describe("legacyIsIPv6ConnectivityErrorCause", () => { ), ).toBe(true); }); + + it("classifies the native rendered EHOSTUNREACH connect error without structured fields", () => { + expect( + legacyIsIPv6ConnectivityErrorCause( + new Error( + "failed to connect to postgres: failed to connect to `host=db.x.supabase.co user=postgres database=postgres`: dial error (connect EHOSTUNREACH 2600:1f18::1:5432)", + ), + ), + ).toBe(true); + expect( + legacyIsIPv6ConnectivityErrorCause( + new Error( + "failed to connect to postgres: failed to connect to `host=db.x.supabase.co user=postgres database=postgres`: dial error (connect EADDRNOTAVAIL 2a05:d014::1:5432)", + ), + ), + ).toBe(true); + }); + + it("classifies the native rendered ENOTFOUND connect error", () => { + expect( + legacyIsIPv6ConnectivityErrorCause( + new Error( + "failed to connect to postgres: failed to connect to `host=db.x.supabase.co user=postgres database=postgres`: hostname resolving error (getaddrinfo ENOTFOUND)", + ), + ), + ).toBe(true); + expect( + legacyIsIPv6ConnectivityErrorCause( + new Error( + "failed to connect to postgres: failed to connect to `host=db.x.supabase.co user=postgres database=postgres`: hostname resolving error (getaddrinfo EAI_AGAIN db.x.supabase.co)", + ), + ), + ).toBe(false); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.ts index 3c5fc8f656..860a7e0051 100644 --- a/apps/cli/src/legacy/shared/legacy-temp-paths.ts +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.ts @@ -38,7 +38,6 @@ export interface LegacyTempPaths { readonly gotrueVersion: string; readonly storageVersion: string; readonly storageMigration: string; - readonly pgmetaVersion: string; readonly linkedProjectCache: string; } @@ -53,7 +52,6 @@ export function legacyTempPaths(path: Path.Path, workdir: string): LegacyTempPat 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/legacy/shared/legacy-temp-paths.unit.test.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts index e861796e10..acd919144f 100644 --- a/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts @@ -37,7 +37,6 @@ describe("legacyTempPaths", () => { 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/tsconfig.json b/apps/cli/tsconfig.json index fe40faa8e6..9f48b70aab 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -17,6 +17,9 @@ // pin its types to the published declarations. Drop this once a fixed // pg-topo release (>= 1.0.0-alpha.6) clears the pnpm minimumReleaseAge // window and is bumped in this package. + // Do not path-pin `@supabase/postgrest-typegen` here: bun follows those + // `.d.ts` re-exports (`./go.ts`) and the compile/docs-spec entrypoints + // fail. `tsconfig.types.json` pins typegen for `tsc` only. "paths": { "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"] } diff --git a/apps/cli/tsconfig.types.json b/apps/cli/tsconfig.types.json new file mode 100644 index 0000000000..138229023a --- /dev/null +++ b/apps/cli/tsconfig.types.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // typegen's `bun` export is unbuilt source and fails under our + // noUncheckedIndexedAccess. Keep this pin off the bun-visible + // tsconfig — bun cannot resolve the `.d.ts` `./go.ts` re-exports. + "paths": { + "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"], + "@supabase/postgrest-typegen/generation": [ + "./node_modules/@supabase/postgrest-typegen/dist/generation/index.d.ts" + ], + "@supabase/postgrest-typegen/introspection": [ + "./node_modules/@supabase/postgrest-typegen/dist/introspection/index.d.ts" + ] + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b764507f5e..262563aec3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,30 @@ importers: '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 + '@oxfmt/binding-darwin-arm64': + specifier: 0.65.0 + version: 0.65.0 + '@oxfmt/binding-darwin-x64': + specifier: 0.65.0 + version: 0.65.0 + '@oxfmt/binding-linux-arm64-gnu': + specifier: 0.65.0 + version: 0.65.0 + '@oxfmt/binding-linux-arm64-musl': + specifier: 0.65.0 + version: 0.65.0 + '@oxfmt/binding-linux-x64-gnu': + specifier: 0.65.0 + version: 0.65.0 + '@oxfmt/binding-linux-x64-musl': + specifier: 0.65.0 + version: 0.65.0 + '@oxfmt/binding-win32-arm64-msvc': + specifier: 0.65.0 + version: 0.65.0 + '@oxfmt/binding-win32-x64-msvc': + specifier: 0.65.0 + version: 0.65.0 '@parcel/watcher': specifier: ^2.6.0 version: 2.6.0 @@ -178,11 +202,14 @@ importers: specifier: workspace:* version: link:../../packages/config '@supabase/pg-delta': - specifier: 1.0.0-alpha.47 - version: 1.0.0-alpha.47(@supabase/pg-topo@1.0.0-alpha.6) + specifier: 1.0.0-alpha.48 + version: 1.0.0-alpha.48(@supabase/pg-topo@1.0.0-alpha.6) '@supabase/pg-topo': specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6 + '@supabase/postgrest-typegen': + specifier: 0.2.1 + version: 0.2.1 '@supabase/process-compose': specifier: workspace:* version: link:../../packages/process-compose @@ -609,6 +636,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'} @@ -2681,8 +2714,8 @@ packages: resolution: {integrity: sha512-DQ0aVH8wSQAccVqNoEkec62qCu2QRNyoGN53RqsVZ1k6F1zq4/v8scrlR6LNT2RJmT97apiTmORijPVhErCS2g==} engines: {node: '>=22.0.0'} - '@supabase/pg-delta@1.0.0-alpha.47': - resolution: {integrity: sha512-0i3hBSv60BqNUvc6VeBUFgHwLDO0kG/p2bsvvDbdQWAgR2f+8ywDMj5a71d34zXHj727AEKEOI2tMcBpBJUTFQ==} + '@supabase/pg-delta@1.0.0-alpha.48': + resolution: {integrity: sha512-niZKwyus/gU0MEH/e9hb9Mv/xiHed7iF6RIILsB5nNO5cUT3K8NYgQHlX5K8TmZjMXfWxofyzW5RKaLA4bCVPQ==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -2701,6 +2734,10 @@ packages: resolution: {integrity: sha512-uaubtPSeg2TR4wrtfQoQWgkTAe+a0qWX2KhmwvTfNl5mGN9+U7owiJt6abk3o/V6O899PSRD1yzxs5RlF4xTug==} engines: {node: '>=22.0.0'} + '@supabase/postgrest-typegen@0.2.1': + resolution: {integrity: sha512-4lfKua/f3h6ssHiYwtyE5JF+msxh0bZA0HRzqGosmGE3N7wmFqtA0kD04cmhvlEZz86RfO2/Z7Epw8oK5JN+/A==} + engines: {node: '>=20.0.0'} + '@supabase/realtime-js@2.112.4': resolution: {integrity: sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw==} engines: {node: '>=22.0.0'} @@ -3223,6 +3260,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==} @@ -6824,6 +6867,12 @@ snapshots: optionalDependencies: zod: 4.4.3 + '@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 @@ -7712,11 +7761,9 @@ snapshots: '@oxfmt/binding-android-arm64@0.65.0': optional: true - '@oxfmt/binding-darwin-arm64@0.65.0': - optional: true + '@oxfmt/binding-darwin-arm64@0.65.0': {} - '@oxfmt/binding-darwin-x64@0.65.0': - optional: true + '@oxfmt/binding-darwin-x64@0.65.0': {} '@oxfmt/binding-freebsd-x64@0.65.0': optional: true @@ -7727,11 +7774,9 @@ snapshots: '@oxfmt/binding-linux-arm-musleabihf@0.65.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.65.0': - optional: true + '@oxfmt/binding-linux-arm64-gnu@0.65.0': {} - '@oxfmt/binding-linux-arm64-musl@0.65.0': - optional: true + '@oxfmt/binding-linux-arm64-musl@0.65.0': {} '@oxfmt/binding-linux-ppc64-gnu@0.65.0': optional: true @@ -7745,23 +7790,19 @@ snapshots: '@oxfmt/binding-linux-s390x-gnu@0.65.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.65.0': - optional: true + '@oxfmt/binding-linux-x64-gnu@0.65.0': {} - '@oxfmt/binding-linux-x64-musl@0.65.0': - optional: true + '@oxfmt/binding-linux-x64-musl@0.65.0': {} '@oxfmt/binding-openharmony-arm64@0.65.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.65.0': - optional: true + '@oxfmt/binding-win32-arm64-msvc@0.65.0': {} '@oxfmt/binding-win32-ia32-msvc@0.65.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.65.0': - optional: true + '@oxfmt/binding-win32-x64-msvc@0.65.0': {} '@oxlint-tsgolint/darwin-arm64@7.0.2001': optional: true @@ -8492,7 +8533,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/pg-delta@1.0.0-alpha.47(@supabase/pg-topo@1.0.0-alpha.6)': + '@supabase/pg-delta@1.0.0-alpha.48(@supabase/pg-topo@1.0.0-alpha.6)': dependencies: debug: 4.4.3 pg: 8.23.0 @@ -8516,6 +8557,14 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/postgrest-typegen@0.2.1': + dependencies: + arktype: 2.2.3 + oxfmt: 0.65.0 + transitivePeerDependencies: + - svelte + - vite-plus + '@supabase/realtime-js@2.112.4': dependencies: '@supabase/phoenix': 0.4.5 @@ -9044,6 +9093,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: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eb9531e556..401fbe85f0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,8 +52,29 @@ minimumReleaseAgeExclude: - "@effect/platform-node-shared@4.0.0-rc.111" - "@effect/sql-pg@4.0.0-rc.111" - "@effect/vitest@4.0.0-rc.111" - - "@supabase/pg-delta@1.0.0-alpha.46" - - "@supabase/pg-topo@1.0.0-alpha.5" + - "@supabase/pg-delta@1.0.0-alpha.48" + - "@supabase/pg-topo@1.0.0-alpha.6" + - "@supabase/postgrest-typegen@0.2.1" + - "oxfmt@0.65.0" + - "@oxfmt/binding-android-arm-eabi@0.65.0" + - "@oxfmt/binding-android-arm64@0.65.0" + - "@oxfmt/binding-darwin-arm64@0.65.0" + - "@oxfmt/binding-darwin-x64@0.65.0" + - "@oxfmt/binding-freebsd-x64@0.65.0" + - "@oxfmt/binding-linux-arm-gnueabihf@0.65.0" + - "@oxfmt/binding-linux-arm-musleabihf@0.65.0" + - "@oxfmt/binding-linux-arm64-gnu@0.65.0" + - "@oxfmt/binding-linux-arm64-musl@0.65.0" + - "@oxfmt/binding-linux-ppc64-gnu@0.65.0" + - "@oxfmt/binding-linux-riscv64-gnu@0.65.0" + - "@oxfmt/binding-linux-riscv64-musl@0.65.0" + - "@oxfmt/binding-linux-s390x-gnu@0.65.0" + - "@oxfmt/binding-linux-x64-gnu@0.65.0" + - "@oxfmt/binding-linux-x64-musl@0.65.0" + - "@oxfmt/binding-openharmony-arm64@0.65.0" + - "@oxfmt/binding-win32-arm64-msvc@0.65.0" + - "@oxfmt/binding-win32-ia32-msvc@0.65.0" + - "@oxfmt/binding-win32-x64-msvc@0.65.0" - "@types/bun@1.4.0" - "bun-types@1.4.0" - "effect@4.0.0-rc.111"