From 039866ac59779d81d1827a94a0939bb29a4a00d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 11:48:29 +0000 Subject: [PATCH 01/14] feat(cli): generate types natively with postgrest-typegen (CLI-2279) Replace the pg-meta Docker container behind `gen types` with the new @supabase/postgrest-typegen package, running introspection and language generation in-process over a direct Postgres connection. - New LegacyGenTypesGenerator service seam: the production layer acquires a scoped pg.Pool via legacyAcquirePgPool (full driver parity: TLS mode, DoH resolver, connect-error mapping), feeds it to introspect(), sorts with sortGeneratorMetadata, and renders typescript/go/python/swift. - `--local` connects to the host-mapped db port instead of spawning the pg-meta image inside the stack network; the `container inspect` stack-running check and rest-version v9 forcing are unchanged. The obsolete `.temp/pgmeta-version` image override is removed. - `--db-url` now resolves through the shared LegacyDbConfigResolver (libpq keywords, options=reference pooler tenants, sslmode, PG* env fallbacks), matching every other --db-url command. - Project-ref non-TypeScript paths and the preview-branch fallback keep their Management API flow and IPv4 pooler retry, now classifying the native connect error instead of container stderr. - `--query-timeout` maps to statement_timeout plus the connect timeout, mirroring the PG_QUERY_TIMEOUT_SECS/PG_CONN_TIMEOUT_SECS envs pg-meta received; `--postgrest-v9-compat` disables one-to-one detection in the TypeScript generator; output keeps the trailing newline console.log added in pg-meta. - The pg-meta SSL probe, CA bundle templates, and --network-id container override are gone with the container; the linked TypeScript path still uses the Management API unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RpnbmgTzwdTMkWYF7uFR1V --- apps/cli/package.json | 1 + .../legacy/commands/gen/types/SIDE_EFFECTS.md | 106 +- .../gen/types/templates/prod-ca-2021.ts | 24 - .../gen/types/templates/prod-ca-2025.ts | 24 - .../gen/types/templates/staging-ca-2021.ts | 24 - .../commands/gen/types/types.e2e.test.ts | 19 +- .../legacy/commands/gen/types/types.errors.ts | 12 +- .../gen/types/types.generator.layer.ts | 98 + .../commands/gen/types/types.generator.ts | 57 + .../commands/gen/types/types.handler.ts | 270 +- .../gen/types/types.integration.test.ts | 3145 +++++++---------- .../legacy/commands/gen/types/types.layers.ts | 8 +- .../legacy/commands/gen/types/types.shared.ts | 89 +- .../commands/gen/types/types.unit.test.ts | 118 +- pnpm-lock.yaml | 54 + pnpm-workspace.yaml | 1 + 16 files changed, 1548 insertions(+), 2502 deletions(-) delete mode 100644 apps/cli/src/legacy/commands/gen/types/templates/prod-ca-2021.ts delete mode 100644 apps/cli/src/legacy/commands/gen/types/templates/prod-ca-2025.ts delete mode 100644 apps/cli/src/legacy/commands/gen/types/templates/staging-ca-2021.ts create mode 100644 apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts create mode 100644 apps/cli/src/legacy/commands/gen/types/types.generator.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index faa2374194..823d6d23ee 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -70,6 +70,7 @@ "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.46", "@supabase/pg-topo": "1.0.0-alpha.5", + "@supabase/postgrest-typegen": "0.1.0", "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", 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 6441e6b534..b5d972128b 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -2,13 +2,12 @@ ## 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`; 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` | ## Files Written @@ -16,9 +15,7 @@ | ---- | ------ | ---- | | — | — | — | -No files are written. Container env (including the DB URL and TLS CA bundle) is -passed via container CLI `run --env KEY=VALUE` arguments; no temporary env-file -is created. +No files are written. ## API Routes @@ -35,42 +32,49 @@ 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` and, when the connection string carries no explicit +`connect_timeout`, as the connect timeout. `--local` connects to the +host-mapped database port from `supabase/config.toml` (`db.port`). + ## 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 | +| 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_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`) | ## Exit Codes @@ -82,14 +86,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` @@ -110,18 +116,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, @@ -130,18 +136,22 @@ 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 +- `--query-timeout` sets the maximum timeout for the introspection queries (default + 15s). 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 runs pg-meta). + 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. - The legacy positional language argument (`supabase gen types typescript`) is still accepted; any other positional language requires an explicit `--lang` flag. - The linked-project telemetry cache is written only when a project ref is resolved diff --git a/apps/cli/src/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..51f2b10fff 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,16 @@ export class LegacyInvalidGenTypesDurationError extends Data.TaggedError( } } -export class LegacyInvalidGenTypesDatabaseUrlError extends Data.TaggedError( - "LegacyInvalidGenTypesDatabaseUrlError", -)<{ +/** + * A `postgrest-typegen` introspection query or language generator failed + * against a live database the CLI successfully connected to. Both stages + * derive entirely from the user's schema contents, so they classify as a + * database finding rather than a CLI defect. + */ +export class LegacyGenTypesMetadataError extends Data.TaggedError("LegacyGenTypesMetadataError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.provideFlags; + return actionability.dbFinding; } } 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..c58677d822 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts @@ -0,0 +1,98 @@ +import { + generateGo, + generatePython, + generateSwift, + generateTypescript, + sortGeneratorMetadata, +} from "@supabase/postgrest-typegen/generation"; +import { introspect } from "@supabase/postgrest-typegen/introspection"; +import { Effect, Layer } from "effect"; + +import { legacyAcquirePgPool } from "../../../shared/legacy-db-connection.sql-pg.layer.ts"; +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyGenTypesMetadataError } from "./types.errors.ts"; +import { type LegacyGenTypesGenerateInput, LegacyGenTypesGenerator } from "./types.generator.ts"; + +function describeCause(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** + * Timeout parity with the retired pg-meta container path, which received + * `PG_QUERY_TIMEOUT_SECS` / `PG_CONN_TIMEOUT_SECS` (both set from + * `--query-timeout`): the query timeout becomes the session's + * `statement_timeout` (in milliseconds, `0` disables it — same as pg-meta's + * driver-level `query_timeout`), and the connect timeout applies only when the + * connection doesn't already carry one (a `--db-url` `connect_timeout` wins). + */ +function applyTimeouts(conn: LegacyPgConnInput, queryTimeoutSeconds: number): LegacyPgConnInput { + return { + ...conn, + connectTimeoutSeconds: conn.connectTimeoutSeconds ?? queryTimeoutSeconds, + runtimeParams: { + statement_timeout: `${queryTimeoutSeconds * 1000}`, + ...conn.runtimeParams, + }, + }; +} + +const generate = (input: LegacyGenTypesGenerateInput) => + Effect.scoped( + Effect.gen(function* () { + const pool = yield* legacyAcquirePgPool( + applyTimeouts(input.conn, input.queryTimeoutSeconds), + { 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. + const metadata = yield* Effect.tryPromise({ + try: () => + introspect( + pool, + input.includedSchemas.length > 0 ? { includedSchemas: [...input.includedSchemas] } : {}, + ), + catch: (cause) => + new LegacyGenTypesMetadataError({ + message: `failed to introspect database: ${describeCause(cause)}`, + }), + }); + + // Canonical sort before generation so output is deterministic regardless + // of the introspection queries' heap order. + const sorted = sortGeneratorMetadata(metadata); + + const metadataError = (cause: unknown) => + new LegacyGenTypesMetadataError({ + message: `failed to generate ${input.lang} types: ${describeCause(cause)}`, + }); + + switch (input.lang) { + case "typescript": + return yield* Effect.tryPromise({ + try: () => + generateTypescript(sorted, { + detectOneToOneRelationships: !input.postgrestV9Compat, + }), + catch: metadataError, + }); + case "go": + return yield* Effect.try({ try: () => generateGo(sorted), catch: metadataError }); + case "python": + return yield* Effect.try({ try: () => generatePython(sorted), catch: metadataError }); + case "swift": + return yield* Effect.try({ + try: () => generateSwift(sorted, { accessControl: input.swiftAccessControl }), + catch: metadataError, + }); + } + }), + ); + +/** + * 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.succeed(LegacyGenTypesGenerator, { generate }); 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..72936e6521 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.ts @@ -0,0 +1,57 @@ +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 { 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 the connection's + * `statement_timeout` and, when the connection carries no explicit connect + * timeout, as the connect timeout — mirroring the `PG_QUERY_TIMEOUT_SECS` / + * `PG_CONN_TIMEOUT_SECS` envs the pg-meta container received. + */ + 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; +} + +/** + * 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 63a47aa0fd..3e1d309b97 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/effect"; 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 { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { cobraMutuallyExclusiveErrorMessage, @@ -19,10 +16,7 @@ import { 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 { 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,23 @@ 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 { 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, parseQueryTimeoutSeconds, - legacyRootCaBundle, - resolvePgmetaImage, } from "./types.shared.ts"; const mapProjectTypesError = mapLegacyHttpError({ @@ -135,16 +123,6 @@ const GEN_TYPES_SCAN_SPEC = { valueFlagShorthands: new Map([["s", "schema"], ...PERSISTENT_VALUE_FLAG_SHORTHANDS]), } as const; -function forwardByteStream( - stream: Stream.Stream, - write: (text: string) => Effect.Effect, -) { - const decoder = new TextDecoder(); - return Stream.runForEach(stream, (chunk) => write(decoder.decode(chunk, { stream: true }))).pipe( - Effect.andThen(write(decoder.decode())), - ); -} - function collectByteStream(stream: Stream.Stream) { const decoder = new TextDecoder(); return Stream.runFold( @@ -226,7 +204,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 +211,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 @@ -297,19 +274,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 +329,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,128 +348,52 @@ 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 args = [ - "run", - "--rm", - "--network", - networkMode, - ...env.flatMap((entry) => ["--env", entry]), - resolvePgmetaImage(input.pgmetaVersionOverride), - "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}`)); - } - }), - ); + // The retired pg-meta container printed the generated output through + // `console.log`, so a single trailing newline is part of the + // established stdout contract. + yield* output.raw(`${types}\n`); + }); const assertLocalDbRunning = (projectId: string) => Effect.scoped( @@ -592,10 +486,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 @@ -603,49 +497,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 ba5355ed6b..3d6eb0dd5d 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,13 +10,11 @@ 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, @@ -25,14 +22,7 @@ import { } 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 +32,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, parseQueryTimeoutSeconds } from "./types.shared.ts"; function writeConfig(workdir: string, contents: string) { const supabaseDir = join(workdir, "supabase"); @@ -89,42 +68,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 +143,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 +180,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 +213,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 +230,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 +333,12 @@ 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(LegacyNetworkIdFlag, Option.none()), Layer.succeed(LegacyPlatformApiFactory, { make: LegacyPlatformApi.pipe(Effect.provide(api.layer)), }), dbConfig.layer, + generator.layer, ); return { @@ -378,6 +347,7 @@ function setup( telemetry, linkedProjectCache, dbConfig, + generator, processControl, child, api, @@ -385,67 +355,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,54 +427,19 @@ 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"); - } - - try { - return await run(address.port); - } finally { - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } -} +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`, +}); 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* () { @@ -574,84 +448,6 @@ describe("legacy gen types", () => { }), ); - it.live("runs tokenless local generation through command wiring", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-command-local-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - const out = mockOutput({ format: "text", interactive: false }); - const analytics = mockAnalytics(); - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { exitCode: 0, 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 +610,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, @@ -1038,56 +833,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 +941,710 @@ 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"); + }); + }); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), - ).toBe(true); + 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"], + }); + + 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)); + + expect(generator.calls[0]?.postgrestV9Compat).toBe(true); + }); + }); 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)), - ); + 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( + { + host: "127.0.0.1", + port: 5432, + 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}`)), + createLoginRole: ({ ref }) => + Effect.fail(new Error(`unexpected login role creation for ${ref}`)), + }); - 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); + 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("resolves the linked workdir DB without ad-hoc project-ref semantics", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--linked"], - projectId: Option.some(LEGACY_VALID_REF), - childStdout: ["type PublicMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "workdir-password", - database: "postgres", - }), - ), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)), - ); + expect(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); + }); + }); + } - expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("linked"); - // --linked is the workdir's own project: keep workdir-scoped credentials. - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + it.live("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 {}", + }); - 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)), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10&options=reference%3D${LEGACY_VALID_REF}`, - ), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + 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("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 = { + 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, + port: 5432, 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)), - ); + options: `reference=${LEGACY_VALID_REF}`, + }), + ), + }); - 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); + 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("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", + // 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}`); + }); + }); + + 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", - }; - 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)), - ); + }), + ), + poolerFallback: Option.some(poolerConn), + }); - expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); - expect(child.spawned).toHaveLength(2); - expect(dbConfig.poolerFallbacks).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("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", - }), - }); + 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); + }); + }); - const exit = await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + 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), + }); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(1); - expect(dbConfig.poolerFallbacks).toHaveLength(0); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); - 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`, - ], - }, - { - 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", - database: "postgres", - }), - }); - - const exit = await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(generator.calls).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }); + }); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(2); - expect(dbConfig.poolerFallbacks).toHaveLength(1); + 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", }), - 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(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", + }), + ), + 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 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."); + "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", }), ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + 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("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", - }), - }); + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); - yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)); + 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."); + }); + }, + ); - expect(out.stdoutText).toContain("type RetriedAfterProbeFailure struct {}"); - expect(probeCalls).toBe(2); - expect(child.spawned).toHaveLength(1); - expect(dbConfig.poolerFallbacks).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("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* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); - 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("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 {}", + }); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(0); - expect(dbConfig.poolerFallbacks).toHaveLength(0); + return Effect.gen(function* () { + try { + 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)); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } - 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), - ); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private"]); + }); + }); - 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); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + 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 {}", + }); - 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 }); - } + return Effect.gen(function* () { + try { + yield* legacyGenTypes( + defaultFlags({ + linked: true, + lang: "go", + }), + ).pipe(Effect.provide(layer)); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private")).toBe( - true, - ); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(false); + expect(generator.calls[0]?.includedSchemas).toEqual(["public", "private"]); + }); + }); + + it.live("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}`)), + }); - 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, - 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 }); - } + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).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); + 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", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + 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", + }, + ]), + }); - 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)), - ); + 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( - docker.env.has( - `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); + 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", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + 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", + }, + ]), + }); - 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); - }), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + return Effect.gen(function* () { + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); - 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); - }), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + 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", () => - 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)), - ); + 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", + }), + }); - 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); + return Effect.gen(function* () { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: 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({ @@ -2293,13 +1667,159 @@ describe("legacy gen types", () => { const exit = yield* legacyGenTypes( defaultFlags({ projectId: Option.some(LEGACY_VALID_REF), - lang: "python", + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("Preview branch database credentials are unavailable"); + } + }); + }); + + it.live("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("Preview branch database credentials are unavailable"); + expect(String(exit.cause)).toContain("failed to retrieve generated types"); } }); }); @@ -2325,331 +1845,209 @@ 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"), - ); - - const { layer, out, child, linkedProjectCache } = setup({ - workdir, - childStdout: ["export type Database = {};"], - childStderr: ["pg-meta warning"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + 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"), + ); - 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()); - // 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))), - }), - ); + const { layer, out, child, linkedProjectCache, generator, dbConfig } = setup({ + workdir, + generatorOutput: "export type Database = {};", + }); - 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)), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - 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))), - }), - ); + 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("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"), - ); + 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 = {};", + }); - 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)), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - 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))), - }), - ); + 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"] }, + ]); + }); + }); - 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"); + 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"), + ); - const { layer } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; + process.env["SUPABASE_DB_PASSWORD"] = "secret-password"; + const { layer, child, generator } = setup({ workdir }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + try { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + 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; + } + } + }); + }); - 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"); + 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"); - const { layer } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + const { layer, generator } = setup({ workdir }); - 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( - 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))), - }), - ); + expect(generator.calls[0]?.postgrestV9Compat).toBe(true); + }); + }); - 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"); + 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"); - const { layer, child } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + const { layer, generator } = setup({ workdir }); - 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(child.spawned[1]?.args).toContain(resolvePgmetaImage("0.99.0")); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(generator.calls[0]?.postgrestV9Compat).toBe(false); + }); + }); - 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 }); + 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 }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).pipe( - Effect.provide(layer), - ), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).pipe( + Effect.provide(layer), + ); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=auth,storage")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(generator.calls[0]?.includedSchemas).toEqual(["auth", "storage"]); + }); + }); - 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"] }); + 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 }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + 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"); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + 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-")); @@ -2887,21 +2285,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* () { @@ -2912,11 +2299,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"); }); }); @@ -2933,25 +2318,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* () { @@ -2961,21 +2334,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"); }); }); @@ -3004,255 +2368,120 @@ 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)), - ); + 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(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))), - }), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); - 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)), - ); + 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", + }), + ), + ], + }); - // 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)), - ); + return Effect.gen(function* () { + const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); - expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(true); - }, "S"), - 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("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)), - ); + 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(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; - } - } + 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("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)), - ); + 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(child.spawned[0]?.args).toContain("custom-network"); - expect(child.spawned[0]?.args).not.toContain("host"); + 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("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)), - ); + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, - ), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + expect(generator.calls[0]?.isLocal).toBe(true); + }); + }); it.live("accepts legacy positional typescript without changing behavior", () => { const { layer } = setup({ @@ -3300,30 +2529,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..b1d586cc2c 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.layers.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.layers.ts @@ -11,8 +11,6 @@ import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; -import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyIdentityStitch, legacyIdentityStitchLayer, @@ -24,6 +22,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 +66,7 @@ export const legacyGenTypesRuntimeLayer = (() => { Layer.provide(httpClient), Layer.provide(legacyIdentityStitchLayer), ), - legacyPgDeltaSslProbeLayer, + legacyGenTypesGeneratorLayer, legacyTelemetryStateLayer, // The one per-command identity stitcher, exposed at top level so // `withLegacyCommandInstrumentation` can read @@ -91,7 +91,7 @@ type LegacyGenTypesServices = | LegacyCliSettings | LegacyProjectRefResolver | LegacyDbConfigResolver - | LegacyPgDeltaSslProbe + | LegacyGenTypesGenerator | LegacyLinkedProjectCache | LegacyTelemetryState | LegacyIdentityStitch 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 e8d8ef7ad3..fbe726cc79 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -1,19 +1,9 @@ import { Effect } from "effect"; -import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-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"; +import { LegacyInvalidGenTypesDurationError } from "./types.errors.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"; - -const LEGACY_DEFAULT_CONNECT_TIMEOUT_SECONDS = 10; +// 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"; const DURATION_UNITS_TO_MILLIS = { ns: 1 / 1_000_000, @@ -31,13 +21,6 @@ const DURATION_PART_PATTERN = new RegExp( "g", ); -export interface LegacyGenTypesDbTarget { - readonly url: string; - readonly host: string; - readonly port: number; - readonly networkMode: "host" | (string & {}); -} - export function defaultSchemas(extraSchemas: ReadonlyArray = []) { return [...new Set(["public", ...extraSchemas])]; } @@ -96,67 +79,3 @@ export function parseQueryTimeoutSeconds( 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)}`, - }), - }); -} - -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 defaultImage = dockerfileServiceImage("pgmeta"); - if (versionOverride === undefined || versionOverride.trim().length === 0) { - return legacyGetRegistryImageUrl(defaultImage); - } - return legacyGetRegistryImageUrl( - replaceImageTag(defaultImage, `v${versionOverride.trim().replace(/^v/i, "")}`), - ); -} - -export function legacyRootCaBundle() { - return `${caStaging2021}${caProd2021}${caProd2025}`; -} - -function replaceImageTag(image: string, tag: string): string { - const tagSeparator = image.lastIndexOf(":"); - if (tagSeparator === -1) { - return image; - } - return `${image.slice(0, tagSeparator + 1)}${tag}`; -} 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 b0c6c9b797..136530f820 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,17 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; +import { localNetworkId } from "../../../shared/legacy-docker-ids.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; import { - buildPostgresUrl, defaultSchemas, - legacyRootCaBundle, localDbContainerId, localDbPassword, - localNetworkId, - parseDatabaseUrl, parseQueryTimeoutSeconds, - resolvePgmetaImage, } from "./types.shared.ts"; function withEnv(key: string, value: string | undefined, run: () => T): T { @@ -85,103 +81,6 @@ describe("parseQueryTimeoutSeconds", () => { ); }); -describe("parseDatabaseUrl", () => { - it.effect("parses a full postgresql url", () => - Effect.gen(function* () { - const result = yield* parseDatabaseUrl("postgresql://user:pw@example.com:6543/mydb"); - expect(result.host).toBe("example.com"); - expect(result.port).toBe(6543); - expect(result.networkMode).toBe("host"); - expect(result.url).toContain("/mydb"); - }), - ); - - it.effect("accepts the postgres:// scheme and defaults the database", () => - Effect.gen(function* () { - const result = yield* parseDatabaseUrl("postgres://user:pw@example.com/"); - expect(result.url).toContain("/postgres"); - }), - ); - - it.effect("defaults the port to 5432 when omitted", () => - Effect.gen(function* () { - const result = yield* parseDatabaseUrl("postgresql://user:pw@example.com/db"); - expect(result.port).toBe(5432); - }), - ); - - it.effect("rejects an unsupported scheme", () => - Effect.gen(function* () { - const exit = yield* parseDatabaseUrl("mysql://user:pw@example.com/db").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); - - it.effect("rejects a malformed connection string", () => - Effect.gen(function* () { - const exit = yield* parseDatabaseUrl("not a url").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); -}); - -describe("resolvePgmetaImage", () => { - it("uses the default pgmeta version when no override is given", () => { - const image = withEnv("SUPABASE_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(), - ); - 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"); - }); - - 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("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("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("schema and id helpers", () => { it("normalizes comma separated and repeated schema flags", () => { // pflag's StringSlice parses each value via encoding/csv with NO @@ -221,19 +120,4 @@ describe("schema and id helpers", () => { 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); - }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef66766800..2db0a3def4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: '@supabase/pg-topo': specifier: 1.0.0-alpha.5 version: 1.0.0-alpha.5 + '@supabase/postgrest-typegen': + specifier: 0.1.0 + version: 0.1.0 '@supabase/process-compose': specifier: workspace:* version: link:../../packages/process-compose @@ -588,6 +591,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'} @@ -2685,6 +2694,10 @@ packages: resolution: {integrity: sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==} engines: {node: '>=22.0.0'} + '@supabase/postgrest-typegen@0.1.0': + resolution: {integrity: sha512-j0R9IVPlinTCVNx3u6irZFWGsL6Hg/B0pE8zxBV8VWhw/vmH+cbeD6BmyU5wBAh4sJ3Xk145HTgVg7kVnEccZA==} + engines: {node: '>=20.0.0'} + '@supabase/realtime-js@2.112.3': resolution: {integrity: sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==} engines: {node: '>=22.0.0'} @@ -3204,6 +3217,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==} @@ -5479,6 +5498,10 @@ packages: peerDependencies: pg: ^8 + pg-format@1.0.4: + resolution: {integrity: sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==} + engines: {node: '>=4.0'} + pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -5619,6 +5642,11 @@ packages: rxjs: optional: true + prettier@3.5.3: + resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + engines: {node: '>=14'} + hasBin: true + prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -6801,6 +6829,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 @@ -8515,6 +8549,12 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/postgrest-typegen@0.1.0': + dependencies: + arktype: 2.2.3 + pg-format: 1.0.4 + prettier: 3.5.3 + '@supabase/realtime-js@2.112.3': dependencies: '@supabase/phoenix': 0.4.5 @@ -9039,6 +9079,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: {} @@ -11615,6 +11665,8 @@ snapshots: dependencies: pg: 8.23.0 + pg-format@1.0.4: {} + pg-int8@1.0.1: {} pg-numeric@1.0.2: {} @@ -11771,6 +11823,8 @@ snapshots: dependencies: '@posthog/core': 1.48.1 + prettier@3.5.3: {} + prettier@3.9.6: {} pretty-ms@9.3.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c816e33358..5ff5f1d3c0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -53,6 +53,7 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-rc.111" - "@supabase/pg-delta@1.0.0-alpha.46" - "@supabase/pg-topo@1.0.0-alpha.5" + - "@supabase/postgrest-typegen@0.1.0" - "@types/bun@1.4.0" - "bun-types@1.4.0" - "effect@4.0.0-rc.111" From 6d8c60d5cf9153374e69704ce2e0a2b91634b016 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 11:59:39 +0000 Subject: [PATCH 02/14] fix(cli): adapt gen types TLS to the server and bundle prettier cleanly Validated the native typegen end-to-end against a real Postgres 16 and against postgres-meta 0.98.0 on the same schema, which surfaced two issues: - The driver requires TLS for remote-looking targets, so `gen types --db-url` against a plain-TCP server (common when self-hosting) failed where the pg-meta path adapted via its SSL probe. Restore that adaptivity in the generator layer: when the DSN carries no explicit sslmode, the shared SSLRequest probe decides whether to connect with sslmode=disable; probe failures keep the TLS default so the real connect error still surfaces. - prettier 3.5.3 (postgrest-typegen's pin) trips a Bun bundler renaming bug under `bun build --compile`, breaking TypeScript generation in the compiled binary only. Override it to the repo's prettier 3.9.6, which bundles cleanly; pg-meta itself floated ^3.3.3, so there is no output-parity concern. Parity results against postgres-meta on the same database: TypeScript and Swift byte-identical; Go and Python identical content with canonical sorted entity ordering (sortGeneratorMetadata) instead of pg-meta's environment-dependent row order. Documented in SIDE_EFFECTS.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RpnbmgTzwdTMkWYF7uFR1V --- .../legacy/commands/gen/types/SIDE_EFFECTS.md | 11 ++++++ .../gen/types/types.generator.layer.ts | 37 ++++++++++++++++--- .../legacy/commands/gen/types/types.layers.ts | 3 +- pnpm-lock.yaml | 10 +---- pnpm-workspace.yaml | 6 +++ 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index b5d972128b..c5d9b4c27d 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -54,6 +54,13 @@ requested language locally. `--query-timeout` is applied as the session's `connect_timeout`, as the connect timeout. `--local` connects to the host-mapped database port from `supabase/config.toml` (`db.port`). +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 probe failure keeps the driver's TLS +default and lets the connection attempt surface the real error. + ## Subprocesses | Command | When | Purpose | @@ -154,5 +161,9 @@ go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only other `--db-url` command. - The legacy positional language argument (`supabase gen types typescript`) is still accepted; any other positional language requires an explicit `--lang` flag. +- Go and Python output now lists entities in the canonical sorted order + (`sortGeneratorMetadata`) instead of pg-meta's environment-dependent SQL row + order; the rendered content is otherwise identical (verified byte-identical + for TypeScript and Swift, whose templates sort internally). - 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/types.generator.layer.ts b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts index c58677d822..fc74d8f65b 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts @@ -10,6 +10,7 @@ import { Effect, Layer } from "effect"; import { legacyAcquirePgPool } from "../../../shared/legacy-db-connection.sql-pg.layer.ts"; import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyGenTypesMetadataError } from "./types.errors.ts"; import { type LegacyGenTypesGenerateInput, LegacyGenTypesGenerator } from "./types.generator.ts"; @@ -36,13 +37,31 @@ function applyTimeouts(conn: LegacyPgConnInput, queryTimeoutSeconds: number): Le }; } -const generate = (input: LegacyGenTypesGenerateInput) => +const generate = (sslProbe: LegacyPgDeltaSslProbe["Service"], input: LegacyGenTypesGenerateInput) => Effect.scoped( Effect.gen(function* () { - const pool = yield* legacyAcquirePgPool( - applyTimeouts(input.conn, input.queryTimeoutSeconds), - { isLocal: input.isLocal, dnsResolver: input.dnsResolver }, - ); + let conn = applyTimeouts(input.conn, input.queryTimeoutSeconds); + // The driver requires TLS for remote targets, but the retired pg-meta + // path adapted to the server: its SSLRequest probe decided whether the + // container connected with TLS at all, so a plain-TCP server (common + // for self-hosted databases) still worked. Keep that adaptivity: when + // the DSN carries no explicit `sslmode`, probe the server and disable + // TLS only when it does not speak SSL. A probe failure keeps the + // driver's TLS default so the real connect error (and its IPv6 pooler + // classification) surfaces from the connection attempt itself. + if (!input.isLocal && conn.sslmode === undefined) { + const useTls = yield* sslProbe + .requireSslForHost(conn.host, conn.port) + .pipe(Effect.orElseSucceed(() => true)); + if (!useTls) { + conn = { ...conn, sslmode: "disable" }; + } + } + + 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` @@ -95,4 +114,10 @@ const generate = (input: LegacyGenTypesGenerateInput) => * driver-layer connection parity (TLS mode, DoH resolver, fallback hosts), * introspected and rendered by `@supabase/postgrest-typegen`. */ -export const legacyGenTypesGeneratorLayer = Layer.succeed(LegacyGenTypesGenerator, { generate }); +export const legacyGenTypesGeneratorLayer = Layer.effect( + LegacyGenTypesGenerator, + Effect.gen(function* () { + const sslProbe = yield* LegacyPgDeltaSslProbe; + return { generate: (input: LegacyGenTypesGenerateInput) => generate(sslProbe, input) }; + }), +); 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 b1d586cc2c..e1dc51d21f 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.layers.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.layers.ts @@ -11,6 +11,7 @@ import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { LegacyIdentityStitch, legacyIdentityStitchLayer, @@ -66,7 +67,7 @@ export const legacyGenTypesRuntimeLayer = (() => { Layer.provide(httpClient), Layer.provide(legacyIdentityStitchLayer), ), - legacyGenTypesGeneratorLayer, + legacyGenTypesGeneratorLayer.pipe(Layer.provide(legacyPgDeltaSslProbeLayer)), legacyTelemetryStateLayer, // The one per-command identity stitcher, exposed at top level so // `withLegacyCommandInstrumentation` can read diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2db0a3def4..f271e0d347 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,6 +63,7 @@ catalogs: overrides: '@launchql/protobufjs>@types/node': 24.10.4 + '@supabase/postgrest-typegen>prettier': 3.9.6 patchedDependencies: '@libpg-query/parser@17.6.10': ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3 @@ -5642,11 +5643,6 @@ packages: rxjs: optional: true - prettier@3.5.3: - resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} - engines: {node: '>=14'} - hasBin: true - prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} @@ -8553,7 +8549,7 @@ snapshots: dependencies: arktype: 2.2.3 pg-format: 1.0.4 - prettier: 3.5.3 + prettier: 3.9.6 '@supabase/realtime-js@2.112.3': dependencies: @@ -11823,8 +11819,6 @@ snapshots: dependencies: '@posthog/core': 1.48.1 - prettier@3.5.3: {} - prettier@3.9.6: {} pretty-ms@9.3.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ff5f1d3c0..c05e2b0a25 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -36,6 +36,12 @@ blockExoticSubdeps: true overrides: # pg-topo's parser chain otherwise resolves bleeding-edge Node globals that conflict with Bun's web types. "@launchql/protobufjs>@types/node": "24.10.4" + # postgrest-typegen pins prettier 3.5.3, whose dist trips a Bun bundler + # renaming bug under `bun build --compile` ("Cannot access 'a4' before + # initialization" inside the TypeScript parser). 3.9.6 — the version this + # repo already uses — bundles cleanly, and pg-meta itself floated ^3.3.3, + # so aligning versions carries no output-parity risk. + "@supabase/postgrest-typegen>prettier": "3.9.6" minimumReleaseAge: 10200 minimumReleaseAgeExclude: From ebb449be927e4dd942af7d8ad9ae653ca408db61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:56:35 +0000 Subject: [PATCH 03/14] feat(cli): upgrade postgrest-typegen to 0.2.0 (oxfmt formatter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.2.0 drops prettier for oxfmt, so the prettier bundling override goes away. oxfmt's ESM dist resolves its napi binding through createRequire(import.meta.url) and lazily imports optional prettier plugins — neither survives `bun build --compile` — so the CLI: - embeds the platform binding statically (the @parcel/watcher pattern: one @oxfmt/binding-* devDependency per shipped target, dispatched on platform/arch/SUPABASE_LIBC in types.oxfmt.ts) and injects it through the generator's new `format` option, verified byte-equivalent to the package's own default formatter; - marks the never-installed optional prettier plugins external in both the dev and release build scripts (shared bundle-externals.ts). Revalidated against a real Postgres 16 from the compiled binary: Go/Swift/Python output is byte-identical to the 0.1.0 integration; TypeScript content is identical with oxfmt's union-wrapping style (three lines differ from the prettier-era output), and source-run vs compiled-binary output is identical. SIDE_EFFECTS.md parity note updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RpnbmgTzwdTMkWYF7uFR1V --- apps/cli/package.json | 10 +- apps/cli/scripts/build-binary.ts | 3 +- apps/cli/scripts/build.ts | 2 + apps/cli/scripts/bundle-externals.ts | 22 ++ .../legacy/commands/gen/types/SIDE_EFFECTS.md | 6 +- .../gen/types/types.generator.layer.ts | 5 + .../legacy/commands/gen/types/types.oxfmt.ts | 107 ++++++++ pnpm-lock.yaml | 248 +++++++++++++++++- pnpm-workspace.yaml | 28 +- 9 files changed, 413 insertions(+), 18 deletions(-) create mode 100644 apps/cli/scripts/bundle-externals.ts create mode 100644 apps/cli/src/legacy/commands/gen/types/types.oxfmt.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index 823d6d23ee..0fd2023d36 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", @@ -70,7 +78,7 @@ "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.46", "@supabase/pg-topo": "1.0.0-alpha.5", - "@supabase/postgrest-typegen": "0.1.0", + "@supabase/postgrest-typegen": "0.2.0", "@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..db1e42e7a7 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.`); 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 c5d9b4c27d..691b660320 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -163,7 +163,9 @@ go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only any other positional language requires an explicit `--lang` flag. - Go and Python output now lists entities in the canonical sorted order (`sortGeneratorMetadata`) instead of pg-meta's environment-dependent SQL row - order; the rendered content is otherwise identical (verified byte-identical - for TypeScript and Swift, whose templates sort internally). + order; the rendered content is otherwise identical (Swift verified + byte-identical — its template sorts internally). TypeScript is formatted by + oxfmt (postgrest-typegen ≥ 0.2.0) instead of pg-meta's prettier: content is + identical, with minor whitespace differences in how long union types wrap. - 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/types.generator.layer.ts b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts index fc74d8f65b..3857c727a4 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts @@ -13,6 +13,7 @@ import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.ser import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyGenTypesMetadataError } from "./types.errors.ts"; import { type LegacyGenTypesGenerateInput, LegacyGenTypesGenerator } from "./types.generator.ts"; +import { legacyOxfmtTypegenFormat } from "./types.oxfmt.ts"; function describeCause(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); @@ -93,6 +94,10 @@ const generate = (sslProbe: LegacyPgDeltaSslProbe["Service"], input: LegacyGenTy 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: metadataError, }); 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..5841dfa2bf --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.oxfmt.ts @@ -0,0 +1,107 @@ +/** + * 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. + * + * 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. + */ + +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 }>; + }>; +} + +function legacyRequireOxfmtBinding(): LegacyOxfmtBinding { + if (process.platform === "darwin") { + if (process.arch === "arm64") { + return require("@oxfmt/binding-darwin-arm64"); + } + if (process.arch === "x64") { + return require("@oxfmt/binding-darwin-x64"); + } + } + + if (process.platform === "linux") { + if (process.arch === "arm64") { + if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") { + return require("@oxfmt/binding-linux-arm64-musl"); + } + return require("@oxfmt/binding-linux-arm64-gnu"); + } + if (process.arch === "x64") { + if (typeof SUPABASE_LIBC !== "undefined" && SUPABASE_LIBC === "musl") { + return require("@oxfmt/binding-linux-x64-musl"); + } + return require("@oxfmt/binding-linux-x64-gnu"); + } + } + + if (process.platform === "win32") { + if (process.arch === "arm64") { + return require("@oxfmt/binding-win32-arm64-msvc"); + } + if (process.arch === "x64") { + return require("@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/pnpm-lock.yaml b/pnpm-lock.yaml index f271e0d347..61d0c94157 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,7 +63,6 @@ catalogs: overrides: '@launchql/protobufjs>@types/node': 24.10.4 - '@supabase/postgrest-typegen>prettier': 3.9.6 patchedDependencies: '@libpg-query/parser@17.6.10': ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3 @@ -142,6 +141,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 @@ -182,8 +205,8 @@ importers: specifier: 1.0.0-alpha.5 version: 1.0.0-alpha.5 '@supabase/postgrest-typegen': - specifier: 0.1.0 - version: 0.1.0 + specifier: 0.2.0 + version: 0.2.0 '@supabase/process-compose': specifier: workspace:* version: link:../../packages/process-compose @@ -1683,42 +1706,84 @@ packages: cpu: [arm] os: [android] + '@oxfmt/binding-android-arm-eabi@0.65.0': + resolution: {integrity: sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxfmt/binding-android-arm64@0.63.0': resolution: {integrity: sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxfmt/binding-android-arm64@0.65.0': + resolution: {integrity: sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxfmt/binding-darwin-arm64@0.63.0': resolution: {integrity: sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxfmt/binding-darwin-arm64@0.65.0': + resolution: {integrity: sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxfmt/binding-darwin-x64@0.63.0': resolution: {integrity: sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxfmt/binding-darwin-x64@0.65.0': + resolution: {integrity: sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxfmt/binding-freebsd-x64@0.63.0': resolution: {integrity: sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxfmt/binding-freebsd-x64@0.65.0': + resolution: {integrity: sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxfmt/binding-linux-arm-gnueabihf@0.63.0': resolution: {integrity: sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': + resolution: {integrity: sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxfmt/binding-linux-arm-musleabihf@0.63.0': resolution: {integrity: sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxfmt/binding-linux-arm-musleabihf@0.65.0': + resolution: {integrity: sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxfmt/binding-linux-arm64-gnu@0.63.0': resolution: {integrity: sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1726,6 +1791,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-arm64-gnu@0.65.0': + resolution: {integrity: sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-arm64-musl@0.63.0': resolution: {integrity: sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1733,6 +1805,13 @@ packages: os: [linux] libc: [musl] + '@oxfmt/binding-linux-arm64-musl@0.65.0': + resolution: {integrity: sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxfmt/binding-linux-ppc64-gnu@0.63.0': resolution: {integrity: sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1740,6 +1819,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-ppc64-gnu@0.65.0': + resolution: {integrity: sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-riscv64-gnu@0.63.0': resolution: {integrity: sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1747,6 +1833,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-riscv64-gnu@0.65.0': + resolution: {integrity: sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-riscv64-musl@0.63.0': resolution: {integrity: sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1754,6 +1847,13 @@ packages: os: [linux] libc: [musl] + '@oxfmt/binding-linux-riscv64-musl@0.65.0': + resolution: {integrity: sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxfmt/binding-linux-s390x-gnu@0.63.0': resolution: {integrity: sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1761,6 +1861,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-s390x-gnu@0.65.0': + resolution: {integrity: sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-x64-gnu@0.63.0': resolution: {integrity: sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1768,6 +1875,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-x64-gnu@0.65.0': + resolution: {integrity: sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-x64-musl@0.63.0': resolution: {integrity: sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1775,30 +1889,61 @@ packages: os: [linux] libc: [musl] + '@oxfmt/binding-linux-x64-musl@0.65.0': + resolution: {integrity: sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxfmt/binding-openharmony-arm64@0.63.0': resolution: {integrity: sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxfmt/binding-openharmony-arm64@0.65.0': + resolution: {integrity: sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxfmt/binding-win32-arm64-msvc@0.63.0': resolution: {integrity: sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxfmt/binding-win32-arm64-msvc@0.65.0': + resolution: {integrity: sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxfmt/binding-win32-ia32-msvc@0.63.0': resolution: {integrity: sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxfmt/binding-win32-ia32-msvc@0.65.0': + resolution: {integrity: sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxfmt/binding-win32-x64-msvc@0.63.0': resolution: {integrity: sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxfmt/binding-win32-x64-msvc@0.65.0': + resolution: {integrity: sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint-tsgolint/darwin-arm64@7.0.2001': resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} cpu: [arm64] @@ -2695,8 +2840,8 @@ packages: resolution: {integrity: sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==} engines: {node: '>=22.0.0'} - '@supabase/postgrest-typegen@0.1.0': - resolution: {integrity: sha512-j0R9IVPlinTCVNx3u6irZFWGsL6Hg/B0pE8zxBV8VWhw/vmH+cbeD6BmyU5wBAh4sJ3Xk145HTgVg7kVnEccZA==} + '@supabase/postgrest-typegen@0.2.0': + resolution: {integrity: sha512-y+dQsjV0D9IVQ2wW0WBl48owyD/88X8dh78XE4rSo1s8MegCOc5/ZNAOkBVICwTySm/hIk2Iw+0zPqJiEh0XQg==} engines: {node: '>=20.0.0'} '@supabase/realtime-js@2.112.3': @@ -5355,6 +5500,19 @@ packages: vite-plus: optional: true + oxfmt@0.65.0: + resolution: {integrity: sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + oxlint-tsgolint@7.0.2001: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true @@ -7734,60 +7892,109 @@ snapshots: '@oxfmt/binding-android-arm-eabi@0.63.0': optional: true + '@oxfmt/binding-android-arm-eabi@0.65.0': + optional: true + '@oxfmt/binding-android-arm64@0.63.0': optional: true + '@oxfmt/binding-android-arm64@0.65.0': + optional: true + '@oxfmt/binding-darwin-arm64@0.63.0': optional: true + '@oxfmt/binding-darwin-arm64@0.65.0': {} + '@oxfmt/binding-darwin-x64@0.63.0': optional: true + '@oxfmt/binding-darwin-x64@0.65.0': {} + '@oxfmt/binding-freebsd-x64@0.63.0': optional: true + '@oxfmt/binding-freebsd-x64@0.65.0': + optional: true + '@oxfmt/binding-linux-arm-gnueabihf@0.63.0': optional: true + '@oxfmt/binding-linux-arm-gnueabihf@0.65.0': + optional: true + '@oxfmt/binding-linux-arm-musleabihf@0.63.0': optional: true + '@oxfmt/binding-linux-arm-musleabihf@0.65.0': + optional: true + '@oxfmt/binding-linux-arm64-gnu@0.63.0': optional: true + '@oxfmt/binding-linux-arm64-gnu@0.65.0': {} + '@oxfmt/binding-linux-arm64-musl@0.63.0': optional: true + '@oxfmt/binding-linux-arm64-musl@0.65.0': {} + '@oxfmt/binding-linux-ppc64-gnu@0.63.0': optional: true + '@oxfmt/binding-linux-ppc64-gnu@0.65.0': + optional: true + '@oxfmt/binding-linux-riscv64-gnu@0.63.0': optional: true + '@oxfmt/binding-linux-riscv64-gnu@0.65.0': + optional: true + '@oxfmt/binding-linux-riscv64-musl@0.63.0': optional: true + '@oxfmt/binding-linux-riscv64-musl@0.65.0': + optional: true + '@oxfmt/binding-linux-s390x-gnu@0.63.0': optional: true + '@oxfmt/binding-linux-s390x-gnu@0.65.0': + optional: true + '@oxfmt/binding-linux-x64-gnu@0.63.0': optional: true + '@oxfmt/binding-linux-x64-gnu@0.65.0': {} + '@oxfmt/binding-linux-x64-musl@0.63.0': optional: true + '@oxfmt/binding-linux-x64-musl@0.65.0': {} + '@oxfmt/binding-openharmony-arm64@0.63.0': optional: true + '@oxfmt/binding-openharmony-arm64@0.65.0': + optional: true + '@oxfmt/binding-win32-arm64-msvc@0.63.0': optional: true + '@oxfmt/binding-win32-arm64-msvc@0.65.0': {} + '@oxfmt/binding-win32-ia32-msvc@0.63.0': optional: true + '@oxfmt/binding-win32-ia32-msvc@0.65.0': + optional: true + '@oxfmt/binding-win32-x64-msvc@0.63.0': optional: true + '@oxfmt/binding-win32-x64-msvc@0.65.0': {} + '@oxlint-tsgolint/darwin-arm64@7.0.2001': optional: true @@ -8545,11 +8752,14 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/postgrest-typegen@0.1.0': + '@supabase/postgrest-typegen@0.2.0': dependencies: arktype: 2.2.3 + oxfmt: 0.65.0 pg-format: 1.0.4 - prettier: 3.9.6 + transitivePeerDependencies: + - svelte + - vite-plus '@supabase/realtime-js@2.112.3': dependencies: @@ -11518,6 +11728,30 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.63.0 '@oxfmt/binding-win32-x64-msvc': 0.63.0 + oxfmt@0.65.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@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 + oxlint-tsgolint@7.0.2001: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 7.0.2001 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c05e2b0a25..e71492de8e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -36,12 +36,6 @@ blockExoticSubdeps: true overrides: # pg-topo's parser chain otherwise resolves bleeding-edge Node globals that conflict with Bun's web types. "@launchql/protobufjs>@types/node": "24.10.4" - # postgrest-typegen pins prettier 3.5.3, whose dist trips a Bun bundler - # renaming bug under `bun build --compile` ("Cannot access 'a4' before - # initialization" inside the TypeScript parser). 3.9.6 — the version this - # repo already uses — bundles cleanly, and pg-meta itself floated ^3.3.3, - # so aligning versions carries no output-parity risk. - "@supabase/postgrest-typegen>prettier": "3.9.6" minimumReleaseAge: 10200 minimumReleaseAgeExclude: @@ -59,7 +53,27 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-rc.111" - "@supabase/pg-delta@1.0.0-alpha.46" - "@supabase/pg-topo@1.0.0-alpha.5" - - "@supabase/postgrest-typegen@0.1.0" + - "@supabase/postgrest-typegen@0.2.0" + - "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" From 446f55d297ed194f0f5456e96b51ce2c27bb3288 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 31 Aug 2026 18:06:39 +0200 Subject: [PATCH 04/14] fix(cli): restore gen types TLS pinning and timeout parity Pin the embedded Supabase CA when the SSL probe reports TLS, treat --query-timeout 0 as disabled rather than an immediate connect timeout, let the flag override a DSN statement_timeout, bound introspect() on the client, and classify generator/formatter failures as internal instead of database findings. Co-authored-by: Cursor --- .../legacy/commands/gen/types/types.errors.ts | 18 +++- .../gen/types/types.generator.layer.ts | 98 ++++++++++++------- .../commands/gen/types/types.generator.ts | 15 +-- .../legacy/commands/gen/types/types.shared.ts | 42 ++++++++ .../commands/gen/types/types.unit.test.ts | 53 ++++++++++ 5 files changed, 180 insertions(+), 46 deletions(-) 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 51f2b10fff..2f4ac908e7 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.errors.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.errors.ts @@ -40,10 +40,8 @@ export class LegacyInvalidGenTypesDurationError extends Data.TaggedError( } /** - * A `postgrest-typegen` introspection query or language generator failed - * against a live database the CLI successfully connected to. Both stages - * derive entirely from the user's schema contents, so they classify as a - * database finding rather than a CLI defect. + * 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; @@ -52,3 +50,15 @@ export class LegacyGenTypesMetadataError extends Data.TaggedError("LegacyGenType 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.ts b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts index 3857c727a4..b010aaeb64 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts @@ -6,56 +6,62 @@ import { sortGeneratorMetadata, } from "@supabase/postgrest-typegen/generation"; import { introspect } from "@supabase/postgrest-typegen/introspection"; -import { Effect, Layer } from "effect"; +import { Duration, Effect, FileSystem, Layer, Path } from "effect"; import { legacyAcquirePgPool } from "../../../shared/legacy-db-connection.sql-pg.layer.ts"; -import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import { LEGACY_PG_DELTA_CA_BUNDLE } from "../../../shared/legacy-pgdelta-ssl.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { LegacyGenTypesMetadataError } from "./types.errors.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); } -/** - * Timeout parity with the retired pg-meta container path, which received - * `PG_QUERY_TIMEOUT_SECS` / `PG_CONN_TIMEOUT_SECS` (both set from - * `--query-timeout`): the query timeout becomes the session's - * `statement_timeout` (in milliseconds, `0` disables it — same as pg-meta's - * driver-level `query_timeout`), and the connect timeout applies only when the - * connection doesn't already carry one (a `--db-url` `connect_timeout` wins). - */ -function applyTimeouts(conn: LegacyPgConnInput, queryTimeoutSeconds: number): LegacyPgConnInput { - return { - ...conn, - connectTimeoutSeconds: conn.connectTimeoutSeconds ?? queryTimeoutSeconds, - runtimeParams: { - statement_timeout: `${queryTimeoutSeconds * 1000}`, - ...conn.runtimeParams, - }, - }; -} +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"], input: LegacyGenTypesGenerateInput) => +const generate = ( + sslProbe: LegacyPgDeltaSslProbe["Service"], + fs: FileSystem.FileSystem, + path: Path.Path, + input: LegacyGenTypesGenerateInput, +) => Effect.scoped( Effect.gen(function* () { - let conn = applyTimeouts(input.conn, input.queryTimeoutSeconds); + let conn = applyQueryTimeouts(input.conn, input.queryTimeoutSeconds); // The driver requires TLS for remote targets, but the retired pg-meta // path adapted to the server: its SSLRequest probe decided whether the // container connected with TLS at all, so a plain-TCP server (common // for self-hosted databases) still worked. Keep that adaptivity: when // the DSN carries no explicit `sslmode`, probe the server and disable - // TLS only when it does not speak SSL. A probe failure keeps the - // driver's TLS default so the real connect error (and its IPv6 pooler - // classification) surfaces from the connection attempt itself. + // TLS only when it does not speak SSL. A TLS server gets the same CA + // pin pg-meta received via `PG_META_DB_SSL_ROOT_CERT`. A probe failure + // keeps the driver's TLS default so the real connect error (and its + // IPv6 pooler classification) surfaces from the connection attempt. if (!input.isLocal && conn.sslmode === undefined) { const useTls = yield* sslProbe .requireSslForHost(conn.host, conn.port) .pipe(Effect.orElseSucceed(() => true)); if (!useTls) { - conn = { ...conn, sslmode: "disable" }; + conn = applyProbedSslMode(conn, false); + } else { + const sslrootcert = yield* pinProbedCaBundle(fs, path); + conn = applyProbedSslMode(conn, true, sslrootcert); } } @@ -66,8 +72,10 @@ const generate = (sslProbe: LegacyPgDeltaSslProbe["Service"], input: LegacyGenTy // `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. - const metadata = yield* Effect.tryPromise({ + // 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, @@ -78,13 +86,27 @@ const generate = (sslProbe: LegacyPgDeltaSslProbe["Service"], input: LegacyGenTy 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 metadataError = (cause: unknown) => - new LegacyGenTypesMetadataError({ + const generateError = (cause: unknown) => + new LegacyGenTypesGenerateError({ message: `failed to generate ${input.lang} types: ${describeCause(cause)}`, }); @@ -99,16 +121,16 @@ const generate = (sslProbe: LegacyPgDeltaSslProbe["Service"], input: LegacyGenTy // addon inside the compiled binary. format: legacyOxfmtTypegenFormat, }), - catch: metadataError, + catch: generateError, }); case "go": - return yield* Effect.try({ try: () => generateGo(sorted), catch: metadataError }); + return yield* Effect.try({ try: () => generateGo(sorted), catch: generateError }); case "python": - return yield* Effect.try({ try: () => generatePython(sorted), catch: metadataError }); + return yield* Effect.try({ try: () => generatePython(sorted), catch: generateError }); case "swift": return yield* Effect.try({ try: () => generateSwift(sorted, { accessControl: input.swiftAccessControl }), - catch: metadataError, + catch: generateError, }); } }), @@ -123,6 +145,10 @@ export const legacyGenTypesGeneratorLayer = Layer.effect( LegacyGenTypesGenerator, Effect.gen(function* () { const sslProbe = yield* LegacyPgDeltaSslProbe; - return { generate: (input: LegacyGenTypesGenerateInput) => generate(sslProbe, input) }; + 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 index 72936e6521..ec5894e43a 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.generator.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.ts @@ -2,7 +2,7 @@ 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 { LegacyGenTypesMetadataError } from "./types.errors.ts"; +import type { LegacyGenTypesGenerateError, LegacyGenTypesMetadataError } from "./types.errors.ts"; export type LegacyGenTypesLang = "typescript" | "go" | "swift" | "python"; @@ -26,10 +26,10 @@ export interface LegacyGenTypesGenerateInput { /** `--swift-access-control` (Swift generator only). */ readonly swiftAccessControl: "internal" | "public"; /** - * `--query-timeout` in whole seconds. Applied as the connection's - * `statement_timeout` and, when the connection carries no explicit connect - * timeout, as the connect timeout — mirroring the `PG_QUERY_TIMEOUT_SECS` / - * `PG_CONN_TIMEOUT_SECS` envs the pg-meta container received. + * `--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; } @@ -42,7 +42,10 @@ interface LegacyGenTypesGeneratorShape { */ readonly generate: ( input: LegacyGenTypesGenerateInput, - ) => Effect.Effect; + ) => Effect.Effect< + string, + LegacyDbConnectError | LegacyGenTypesGenerateError | LegacyGenTypesMetadataError + >; } /** 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 fbe726cc79..f0b44cfda0 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -1,4 +1,6 @@ import { Effect } from "effect"; + +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; import { LegacyInvalidGenTypesDurationError } from "./types.errors.ts"; // The local Docker container id is hoisted to `legacy/shared` so the declarative @@ -79,3 +81,43 @@ export function parseQueryTimeoutSeconds( export function localDbPassword() { return process.env["SUPABASE_DB_PASSWORD"] ?? "postgres"; } + +/** + * `--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 }; +} + +/** + * 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 } : {}), + }; +} 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 136530f820..4211f0816e 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 @@ -4,12 +4,22 @@ import { localNetworkId } from "../../../shared/legacy-docker-ids.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; import { + applyProbedSslMode, + applyQueryTimeouts, defaultSchemas, localDbContainerId, localDbPassword, parseQueryTimeoutSeconds, } from "./types.shared.ts"; +const BASE_CONN = { + host: "db.example.com", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", +}; + function withEnv(key: string, value: string | undefined, run: () => T): T { const previous = process.env[key]; if (value === undefined) { @@ -81,6 +91,49 @@ describe("parseQueryTimeoutSeconds", () => { ); }); +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(conn.runtimeParams).toEqual({ + statement_timeout: "15000", + search_path: "public", + }); + expect(conn.connectTimeoutSeconds).toBe(15); + }); + + 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("keeps an explicit DSN connect_timeout", () => { + const conn = applyQueryTimeouts({ ...BASE_CONN, connectTimeoutSeconds: 30 }, 15); + expect(conn.connectTimeoutSeconds).toBe(30); + }); +}); + +describe("applyProbedSslMode", () => { + it("disables TLS when the probe reports a plain-TCP server", () => { + expect(applyProbedSslMode(BASE_CONN, false).sslmode).toBe("disable"); + }); + + 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("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 From 2d2cd81830e15a55fb0f3afbae177f37e0f05300 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 31 Aug 2026 18:08:14 +0200 Subject: [PATCH 05/14] fix(cli): pin gen types CA only when the SSL probe reports TLS A probe error still leaves sslmode unset so the driver default and IPv6 pooler classification stay intact. Co-authored-by: Cursor --- .../gen/types/types.generator.layer.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 index b010aaeb64..991edab1d3 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts @@ -6,7 +6,7 @@ import { sortGeneratorMetadata, } from "@supabase/postgrest-typegen/generation"; import { introspect } from "@supabase/postgrest-typegen/introspection"; -import { Duration, Effect, FileSystem, Layer, Path } from "effect"; +import { Duration, Effect, FileSystem, Layer, Path, Result } from "effect"; import { legacyAcquirePgPool } from "../../../shared/legacy-db-connection.sql-pg.layer.ts"; import { LEGACY_PG_DELTA_CA_BUNDLE } from "../../../shared/legacy-pgdelta-ssl.ts"; @@ -54,14 +54,17 @@ const generate = ( // keeps the driver's TLS default so the real connect error (and its // IPv6 pooler classification) surfaces from the connection attempt. if (!input.isLocal && conn.sslmode === undefined) { - const useTls = yield* sslProbe - .requireSslForHost(conn.host, conn.port) - .pipe(Effect.orElseSucceed(() => true)); - if (!useTls) { - conn = applyProbedSslMode(conn, false); - } else { - const sslrootcert = yield* pinProbedCaBundle(fs, path); - conn = applyProbedSslMode(conn, true, sslrootcert); + // A probe error keeps the driver's TLS default so the real connect + // error (and its IPv6 pooler classification) surfaces from the + // connection attempt itself. + const probed = yield* sslProbe.requireSslForHost(conn.host, conn.port).pipe(Effect.result); + if (Result.isSuccess(probed)) { + if (!probed.success) { + conn = applyProbedSslMode(conn, false); + } else { + const sslrootcert = yield* pinProbedCaBundle(fs, path); + conn = applyProbedSslMode(conn, true, sslrootcert); + } } } From 39cb1577fea947c7fcb1d6be632c8f86d0e2d95c Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 31 Aug 2026 18:14:25 +0200 Subject: [PATCH 06/14] chore(cli): trim leftover gen types review nits Drop the duplicate TLS-probe comment, unused network-id test wiring, and a leftover localNetworkId assertion. Clarify that a TLS probe replaces sslrootcert when sslmode is omitted. Co-authored-by: Cursor --- apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md | 4 +++- .../src/legacy/commands/gen/types/types.generator.layer.ts | 3 --- .../src/legacy/commands/gen/types/types.integration.test.ts | 2 -- apps/cli/src/legacy/commands/gen/types/types.unit.test.ts | 2 -- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index 71acf48b86..1a59a48d14 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -69,7 +69,9 @@ 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 keeps the driver's TLS default and lets the connection attempt surface the real error. An explicit -`sslmode` / `sslrootcert` on the DSN is left unchanged. +`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 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 index 991edab1d3..203e289f09 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts @@ -54,9 +54,6 @@ const generate = ( // keeps the driver's TLS default so the real connect error (and its // IPv6 pooler classification) surfaces from the connection attempt. if (!input.isLocal && conn.sslmode === undefined) { - // A probe error keeps the driver's TLS default so the real connect - // error (and its IPv6 pooler classification) surfaces from the - // connection attempt itself. const probed = yield* sslProbe.requireSslForHost(conn.host, conn.port).pipe(Effect.result); if (Result.isSuccess(probed)) { if (!probed.success) { 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 3d6eb0dd5d..085241e401 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 @@ -17,7 +17,6 @@ import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stdio, Stre import { LegacyDebugFlag, LegacyDnsResolverFlag, - LegacyNetworkIdFlag, LegacyOutputFlag, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; @@ -333,7 +332,6 @@ function setup( Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), Layer.succeed(LegacyDebugFlag, opts.debug ?? false), Layer.succeed(LegacyDnsResolverFlag, "native" as const), - Layer.succeed(LegacyNetworkIdFlag, Option.none()), Layer.succeed(LegacyPlatformApiFactory, { make: LegacyPlatformApi.pipe(Effect.provide(api.layer)), }), 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 4211f0816e..c02953cbf3 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,6 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; -import { localNetworkId } from "../../../shared/legacy-docker-ids.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; import { @@ -153,7 +152,6 @@ describe("schema and id helpers", () => { 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", () => { From 9e2e29d23d0fcf907008573335f5cc48a3fcf75a Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 31 Aug 2026 19:23:02 +0200 Subject: [PATCH 07/14] fix(cli): mark oxfmt optional plugins external on musl builds The glibc and local compile paths already pass oxfmtExternalArgs; musl release binaries were still resolving those never-installed prettier plugins and would fail bun build --compile. Co-authored-by: Cursor --- apps/cli/scripts/build.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index db1e42e7a7..336b7141cf 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -303,6 +303,7 @@ async function buildMuslBinaries() { `--define=SUPABASE_LIBC=${JSON.stringify(libc)}`, serveMainTemplateDefine, ...posthogBuildDefines, + ...oxfmtExternalArgs, `--outfile=${outfile}`, ]); From b3fd2dccaea1ebc0fb2ad66b3a19f3cf2e1521e8 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 1 Sep 2026 15:59:04 +0200 Subject: [PATCH 08/14] fix(cli): retry gen types pooler fallback on native ENOTFOUND The native driver drops errno fields before classification, so IPv4-only hosts that fail as hostname resolving error (getaddrinfo ENOTFOUND) never retried through the pooler. Co-authored-by: Cursor --- .../gen/types/types.integration.test.ts | 48 +++++++++++++++++++ .../legacy/shared/legacy-connect-errors.ts | 9 +++- .../shared/legacy-connect-errors.unit.test.ts | 24 ++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) 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 085241e401..5993881273 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 @@ -429,6 +429,10 @@ 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`, }); +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", output: "type PublicMovies struct {}" }, { lang: "swift", output: "struct PublicMovies: Codable {}" }, @@ -1200,6 +1204,50 @@ describe("legacy gen types", () => { }); }); + 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), + lang: "go", + }), + ).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(generator.calls).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }); + }, + ); + 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], diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 80111ef1e0..6f46b679c8 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -455,6 +455,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 +491,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..ff938e4301 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 @@ -88,6 +88,13 @@ describe("legacyIsIPv6ConnectivityError", () => { 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 +600,21 @@ describe("legacyIsIPv6ConnectivityErrorCause", () => { ), ).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); + }); }); From 2edc793cb01d71c761af6d1953a2e409f096b849 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 1 Sep 2026 16:30:24 +0200 Subject: [PATCH 09/14] fix(cli): typecheck published typegen types instead of bun source CI tsc follows the package `bun` export into postgrest-typegen source, which fails under our noUncheckedIndexedAccess and has no pg-format types. Co-authored-by: Cursor --- AGENTS.md | 2 ++ apps/cli/tsconfig.json | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ad0cb7f183..e438e52174 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,6 +214,8 @@ Inside Effect code, compose schemas through their Effect APIs: ## Code Quality +Never `git commit` or `git push` until lint and `types:check` have been run and passed for the change. Targeted unit/integration tests are not a substitute — CI Check code quality runs `pnpm check:all` (`types:check`, oxlint, oxfmt, knip). Before commit or push, from each changed TypeScript workspace run `pnpm types:check`, and from the repo root run `pnpm exec oxlint` (or `pnpm check:all`). If those fail, fix them before committing. + Run repo-wide quality checks from the repository root with `pnpm check:all` or `pnpm fix:all`; these root scripts are the only quality entrypoints and delegate orchestration to Turbo. For package-local work, run `pnpm types:check` and the applicable package test scripts from the workspace you changed. Do not consider a task complete until all relevant scripts pass. Do not waive or defer failing checks in a changed workspace as "pre-existing". If a required check fails, fix it before closing the task. Only treat a failure as an external blocker when it cannot be resolved within the workspace, and in that case call it out explicitly. If you run a root quality command such as `pnpm check:all`, you own all failing checks it reports for the duration of the task, even if the failing files look unrelated. Do not leave the repository with unresolved failing checks after running the command. diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 50b81a2098..1cbd90c381 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,4 +1,17 @@ { "extends": "@tsconfig/bun/tsconfig.json", - "exclude": ["supabase", "src/shared/workers/stacks"] + // Published packages that export `bun` → TypeScript source. tsc under that + // condition typechecks their src with our noUncheckedIndexedAccess. + "exclude": ["node_modules", "supabase", "src/shared/workers/stacks"], + "compilerOptions": { + "paths": { + "@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" + ], + "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"] + } + } } From 4894323169a22d5afdaf473225c186f075458db5 Mon Sep 17 00:00:00 2001 From: avallete Date: Tue, 1 Sep 2026 16:51:36 +0200 Subject: [PATCH 10/14] fix(cli): keep typegen type pins off bun's tsconfig Path-mapping postgrest-typegen to its .d.ts made tsc pass but bun followed those declaration re-exports and broke compile plus the docs-spec unit test. Pin typegen only in tsconfig.types.json for types:check. Co-authored-by: Cursor --- AGENTS.md | 1 + apps/cli/package.json | 2 +- apps/cli/tsconfig.json | 16 ++++++---------- apps/cli/tsconfig.types.json | 18 ++++++++++++++++++ 4 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 apps/cli/tsconfig.types.json diff --git a/AGENTS.md b/AGENTS.md index 1dfc2d4814..c64a55e545 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,6 +215,7 @@ Inside Effect code, compose schemas through their Effect APIs: ## Code Quality Never `git commit` or `git push` until lint and `types:check` have been run and passed for the change. Targeted unit/integration tests are not a substitute — CI Check code quality runs `pnpm check:all` (`types:check`, oxlint, oxfmt, knip). Before commit or push, from each changed TypeScript workspace run `pnpm types:check`, and from the repo root run `pnpm exec oxlint` (or `pnpm check:all`). If those fail, fix them before committing. +After every `git push` to a branch that has a PR, check GitHub CI for that PR (`gh pr checks` / `gh run list`) and report whether it is green. If it is not, diagnose and fix; do not leave a red PR as done. Run repo-wide quality checks from the repository root with `pnpm check:all` or `pnpm fix:all`; these root scripts are the only quality entrypoints and delegate orchestration to Turbo. For package-local work, run `pnpm types:check` and the applicable package test scripts from the workspace you changed. Do not consider a task complete until all relevant scripts pass. Do not waive or defer failing checks in a changed workspace as "pre-existing". If a required check fails, fix it before closing the task. Only treat a failure as an external blocker when it cannot be resolved within the workspace, and in that case call it out explicitly. diff --git a/apps/cli/package.json b/apps/cli/package.json index 27cfa9c39b..189735bc23 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", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 0ede2f3bab..9f48b70aab 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -7,7 +7,8 @@ // first — see `packages/config/package.json`'s exports map and // `packages/config/AGENTS.md`'s "Build" section (CLI-2234). This also // affects any OTHER dependency whose own exports map declares a `bun` - // condition (e.g. `@supabase/pg-topo`, `@supabase/postgrest-typegen`). + // condition (e.g. `@supabase/pg-topo`) — see this package's AGENTS.md/PR + // notes for a known collision that surfaces there. "customConditions": ["bun"], // `@supabase/pg-topo` (external, from supabase/pg-toolbelt) also declares // a `bun` exports condition, pointing at its UNBUILT `src/*.ts`, which @@ -16,16 +17,11 @@ // 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. - // Same pin for `@supabase/postgrest-typegen`: `bun` is source that fails - // noUncheckedIndexedAccess and has no pg-format types. + // 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"], - "@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" - ] + "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"] } }, "exclude": ["supabase", "src/shared/workers/stacks"] diff --git a/apps/cli/tsconfig.types.json b/apps/cli/tsconfig.types.json new file mode 100644 index 0000000000..e331fd66fc --- /dev/null +++ b/apps/cli/tsconfig.types.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + // typegen's `bun` export is unbuilt source; it fails under our + // noUncheckedIndexedAccess and has no pg-format types. 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" + ] + } + } +} From 4eb2b6ef30bdd189b6af3b70750cff6b5f3fdfbf Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 2 Sep 2026 11:52:55 +0200 Subject: [PATCH 11/14] fix(cli): generate types with postgrest-typegen 0.2.1 Drop the pg-format compile workaround now that typegen inlines SQL literal escaping. Keep source-run oxfmt loading and a single trailing newline on generated output. Co-authored-by: Cursor --- apps/cli/package.json | 2 +- .../legacy/commands/gen/types/SIDE_EFFECTS.md | 3 +- .../gen/types/types.generator.layer.ts | 12 +--- .../commands/gen/types/types.handler.ts | 9 +-- .../gen/types/types.integration.test.ts | 30 ++++++--- .../legacy/commands/gen/types/types.oxfmt.ts | 64 ++++++++++++++++--- .../commands/gen/types/types.unit.test.ts | 42 +++--------- apps/cli/tsconfig.types.json | 7 +- pnpm-lock.yaml | 17 ++--- pnpm-workspace.yaml | 2 +- 10 files changed, 105 insertions(+), 83 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 189735bc23..affe29cb0f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -78,7 +78,7 @@ "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.46", "@supabase/pg-topo": "1.0.0-alpha.5", - "@supabase/postgrest-typegen": "0.2.0", + "@supabase/postgrest-typegen": "0.2.1", "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", 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 1a59a48d14..edcdc9a4f6 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -177,7 +177,8 @@ go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only 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. + 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 and Python output now lists entities in the canonical sorted order 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 index 203e289f09..84261f5f33 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts @@ -44,15 +44,9 @@ const generate = ( Effect.scoped( Effect.gen(function* () { let conn = applyQueryTimeouts(input.conn, input.queryTimeoutSeconds); - // The driver requires TLS for remote targets, but the retired pg-meta - // path adapted to the server: its SSLRequest probe decided whether the - // container connected with TLS at all, so a plain-TCP server (common - // for self-hosted databases) still worked. Keep that adaptivity: when - // the DSN carries no explicit `sslmode`, probe the server and disable - // TLS only when it does not speak SSL. A TLS server gets the same CA - // pin pg-meta received via `PG_META_DB_SSL_ROOT_CERT`. A probe failure - // keeps the driver's TLS default so the real connect error (and its - // IPv6 pooler classification) surfaces from the connection attempt. + // Remote DSNs without sslmode probe first (pg-meta did): no TLS → + // disable; TLS → require + the CA pin pg-meta got via + // PG_META_DB_SSL_ROOT_CERT. Probe failure keeps the driver default. if (!input.isLocal && conn.sslmode === undefined) { const probed = yield* sslProbe.requireSslForHost(conn.host, conn.port).pipe(Effect.result); if (Result.isSuccess(probed)) { 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 bde89c8f68..ab7ecc8f43 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -229,8 +229,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; @@ -389,10 +387,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le classifyError: legacyIsIPv6ConnectivityErrorCause, }); - // The retired pg-meta container printed the generated output through - // `console.log`, so a single trailing newline is part of the - // established stdout contract. - yield* output.raw(`${types}\n`); + // 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) => 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 5993881273..c08677735a 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 @@ -45,7 +45,7 @@ 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, parseQueryTimeoutSeconds } from "./types.shared.ts"; +import { localDbContainerId } from "./types.shared.ts"; function writeConfig(workdir: string, contents: string) { const supabaseDir = join(workdir, "supabase"); @@ -443,13 +443,6 @@ const nonTypescriptProjectRefScenarios = [ }>; 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("generates typescript types from a project ref", () => { const { layer, out, api, linkedProjectCache, telemetry } = setup({ projectId: Option.some(LEGACY_VALID_REF), @@ -1940,6 +1933,27 @@ describe("legacy gen types", () => { }); }); + 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", + ), + ); + + const { layer, out } = setup({ + workdir, + generatorOutput: "export type Database = {};\n\n", + }); + + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toBe("export type Database = {};\n"); + }); + }); + 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( diff --git a/apps/cli/src/legacy/commands/gen/types/types.oxfmt.ts b/apps/cli/src/legacy/commands/gen/types/types.oxfmt.ts index 5841dfa2bf..c74f074a24 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.oxfmt.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.oxfmt.ts @@ -11,12 +11,20 @@ * 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; /** @@ -40,37 +48,77 @@ interface LegacyOxfmtBinding { }>; } +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 require("@oxfmt/binding-darwin-arm64"); + return loadOxfmtBinding( + () => require("@oxfmt/binding-darwin-arm64"), + "@oxfmt/binding-darwin-arm64", + ); } if (process.arch === "x64") { - return require("@oxfmt/binding-darwin-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 require("@oxfmt/binding-linux-arm64-musl"); + return loadOxfmtBinding( + () => require("@oxfmt/binding-linux-arm64-musl"), + "@oxfmt/binding-linux-arm64-musl", + ); } - return require("@oxfmt/binding-linux-arm64-gnu"); + 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 require("@oxfmt/binding-linux-x64-musl"); + return loadOxfmtBinding( + () => require("@oxfmt/binding-linux-x64-musl"), + "@oxfmt/binding-linux-x64-musl", + ); } - return require("@oxfmt/binding-linux-x64-gnu"); + return loadOxfmtBinding( + () => require("@oxfmt/binding-linux-x64-gnu"), + "@oxfmt/binding-linux-x64-gnu", + ); } } if (process.platform === "win32") { if (process.arch === "arm64") { - return require("@oxfmt/binding-win32-arm64-msvc"); + return loadOxfmtBinding( + () => require("@oxfmt/binding-win32-arm64-msvc"), + "@oxfmt/binding-win32-arm64-msvc", + ); } if (process.arch === "x64") { - return require("@oxfmt/binding-win32-x64-msvc"); + return loadOxfmtBinding( + () => require("@oxfmt/binding-win32-x64-msvc"), + "@oxfmt/binding-win32-x64-msvc", + ); } } 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 c02953cbf3..5f0e440ec1 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,12 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; -import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; -import { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; import { applyProbedSslMode, applyQueryTimeouts, defaultSchemas, - localDbContainerId, localDbPassword, parseQueryTimeoutSeconds, } from "./types.shared.ts"; @@ -88,6 +85,13 @@ describe("parseQueryTimeoutSeconds", () => { expect(Exit.isFailure(exit)).toBe(true); }), ); + + 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); + }), + ); }); describe("applyQueryTimeouts", () => { @@ -133,41 +137,13 @@ describe("applyProbedSslMode", () => { }); }); -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 and password 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"); - }); - - it("truncates an over-long project id to 40 characters", () => { - const longId = "a".repeat(60); - expect(localDbContainerId(longId)).toBe(`supabase_db_${"a".repeat(40)}`); - }); - - 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", - ); + it("reads the db password from the environment", () => { expect(withEnv("SUPABASE_DB_PASSWORD", undefined, () => localDbPassword())).toBe("postgres"); expect(withEnv("SUPABASE_DB_PASSWORD", "secret", () => localDbPassword())).toBe("secret"); }); diff --git a/apps/cli/tsconfig.types.json b/apps/cli/tsconfig.types.json index e331fd66fc..138229023a 100644 --- a/apps/cli/tsconfig.types.json +++ b/apps/cli/tsconfig.types.json @@ -1,10 +1,9 @@ { "extends": "./tsconfig.json", "compilerOptions": { - // typegen's `bun` export is unbuilt source; it fails under our - // noUncheckedIndexedAccess and has no pg-format types. Keep this pin - // off the bun-visible tsconfig — bun cannot resolve the `.d.ts` - // `./go.ts` re-exports. + // 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": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d119372ffc..561eff0b11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,8 +208,8 @@ importers: specifier: 1.0.0-alpha.5 version: 1.0.0-alpha.5 '@supabase/postgrest-typegen': - specifier: 0.2.0 - version: 0.2.0 + specifier: 0.2.1 + version: 0.2.1 '@supabase/process-compose': specifier: workspace:* version: link:../../packages/process-compose @@ -2861,8 +2861,8 @@ packages: resolution: {integrity: sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==} engines: {node: '>=22.0.0'} - '@supabase/postgrest-typegen@0.2.0': - resolution: {integrity: sha512-y+dQsjV0D9IVQ2wW0WBl48owyD/88X8dh78XE4rSo1s8MegCOc5/ZNAOkBVICwTySm/hIk2Iw+0zPqJiEh0XQg==} + '@supabase/postgrest-typegen@0.2.1': + resolution: {integrity: sha512-4lfKua/f3h6ssHiYwtyE5JF+msxh0bZA0HRzqGosmGE3N7wmFqtA0kD04cmhvlEZz86RfO2/Z7Epw8oK5JN+/A==} engines: {node: '>=20.0.0'} '@supabase/realtime-js@2.112.3': @@ -5678,10 +5678,6 @@ packages: peerDependencies: pg: ^8 - pg-format@1.0.4: - resolution: {integrity: sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==} - engines: {node: '>=4.0'} - pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -8773,11 +8769,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@supabase/postgrest-typegen@0.2.0': + '@supabase/postgrest-typegen@0.2.1': dependencies: arktype: 2.2.3 oxfmt: 0.65.0 - pg-format: 1.0.4 transitivePeerDependencies: - svelte - vite-plus @@ -11916,8 +11911,6 @@ snapshots: dependencies: pg: 8.23.0 - pg-format@1.0.4: {} - pg-int8@1.0.1: {} pg-numeric@1.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c51489aae6..a49939356a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -54,7 +54,7 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-rc.111" - "@supabase/pg-delta@1.0.0-alpha.46" - "@supabase/pg-topo@1.0.0-alpha.5" - - "@supabase/postgrest-typegen@0.2.0" + - "@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" From 8ce5cfa7d6e81bcaef44893236b45b0b18de8bc5 Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 2 Sep 2026 12:57:48 +0200 Subject: [PATCH 12/14] fix(cli): reject sub-second gen types timeouts that disable the bound `--query-timeout 1ms` rounded to 0 and silently dropped both timeout guards. Parse through the shared Go duration helper, refuse rounded-to-0 except explicit disable, and document native TS/Python shape diffs. Co-authored-by: Cursor --- .../legacy/commands/gen/types/SIDE_EFFECTS.md | 41 ++++++--- .../commands/gen/types/types.handler.ts | 42 ++------- .../gen/types/types.integration.test.ts | 22 +++++ .../legacy/commands/gen/types/types.shared.ts | 86 ++++++------------- .../commands/gen/types/types.unit.test.ts | 73 ++++++++++++---- 5 files changed, 136 insertions(+), 128 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md index edcdc9a4f6..a2834e56a2 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -54,11 +54,13 @@ 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` disables both. 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`). +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`). 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 @@ -171,21 +173,32 @@ go`/`--lang swift`/`--lang python` — the defaults-only claim above holds only intentionally). `--local` still forces v9 compat when the local PostgREST image tag contains `v9`. - `--query-timeout` sets the maximum timeout for the introspection queries (default - 15s). 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). + 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 and Python output now lists entities in the canonical sorted order +- Go output lists entities in the canonical sorted order (`sortGeneratorMetadata`) instead of pg-meta's environment-dependent SQL row - order; the rendered content is otherwise identical (Swift verified - byte-identical — its template sorts internally). TypeScript is formatted by - oxfmt (postgrest-typegen ≥ 0.2.0) instead of pg-meta's prettier: content is - identical, with minor whitespace differences in how long union types wrap. + 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/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index ab7ecc8f43..2ae31ae6c6 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -1,6 +1,6 @@ import { loadCliConfig } from "@supabase/config/internal"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; +import { Effect, FileSystem, Option, Path, Stdio } from "effect"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { @@ -15,7 +15,7 @@ import { LegacyProjectRefResolver, PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/legacy-project-ref.service.ts"; -import { spawnContainerCli } from "../../../shared/legacy-container-cli.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"; @@ -123,38 +123,10 @@ const GEN_TYPES_SCAN_SPEC = { valueFlagShorthands: new Map([["s", "schema"], ...PERSISTENT_VALUE_FLAG_SHORTHANDS]), } as const; -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( @@ -409,7 +381,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(); 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 c08677735a..27e0329b5d 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 @@ -806,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"], 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 f0b44cfda0..70dfd7a006 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -1,81 +1,47 @@ import { Effect } from "effect"; import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; import { LegacyInvalidGenTypesDurationError } from "./types.errors.ts"; // 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"; -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 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() { 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 5f0e440ec1..6df0f21183 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,3 +1,6 @@ +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 { @@ -8,6 +11,13 @@ import { parseQueryTimeoutSeconds, } from "./types.shared.ts"; +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, @@ -44,52 +54,55 @@ describe("parseQueryTimeoutSeconds", () => { }), ); - it.effect("rounds sub-second durations to whole seconds", () => + it.effect("accepts Go's bare 0 as disable", () => + Effect.gen(function* () { + expect(yield* parseQueryTimeoutSeconds("0")).toBe(0); + expect(yield* parseQueryTimeoutSeconds("0s")).toBe(0); + expect(yield* parseQueryTimeoutSeconds("0ms")).toBe(0); + }), + ); + + 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 a positive duration that would disable the timeout", () => + Effect.gen(function* () { + for (const raw of ["1ms", "400ms", `15${"µ"}s`, `15${"μ"}s`]) { + expectInvalidDuration(yield* parseQueryTimeoutSeconds(raw).pipe(Effect.exit), raw); + } }), ); it.effect("rejects an empty duration", () => Effect.gen(function* () { - const exit = yield* parseQueryTimeoutSeconds(" ").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); + expectInvalidDuration(yield* parseQueryTimeoutSeconds(" ").pipe(Effect.exit), " "); }), ); 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); + expectInvalidDuration(yield* parseQueryTimeoutSeconds("x15s").pipe(Effect.exit), "x15s"); }), ); 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); + expectInvalidDuration(yield* parseQueryTimeoutSeconds("15s30").pipe(Effect.exit), "15s30"); }), ); 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); + expectInvalidDuration(yield* parseQueryTimeoutSeconds("abc").pipe(Effect.exit), "abc"); }), ); it.effect("rejects a negative duration", () => Effect.gen(function* () { - const exit = yield* parseQueryTimeoutSeconds("-5s").pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }), - ); - - 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); + expectInvalidDuration(yield* parseQueryTimeoutSeconds("-5s").pipe(Effect.exit), "-5s"); }), ); }); @@ -148,3 +161,25 @@ describe("schema and password helpers", () => { expect(withEnv("SUPABASE_DB_PASSWORD", "secret", () => localDbPassword())).toBe("secret"); }); }); + +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(bindingPins.length).toBeGreaterThan(0); + for (const [, version] of bindingPins) { + expect(version).toBe(oxfmtVersion); + } + }); +}); From 5b2115a2b450c97286395f6c7566599ac56ebe1a Mon Sep 17 00:00:00 2001 From: avallete Date: Wed, 2 Sep 2026 19:03:24 +0200 Subject: [PATCH 13/14] fix(cli): warn when gen types ignores --network-id Native generation cannot join a Docker network. Surface a docker-run workaround and classify native IPv6 dial failures so the pooler retry still runs. Co-authored-by: Cursor --- AGENTS.md | 3 -- .../legacy/commands/gen/types/SIDE_EFFECTS.md | 9 ++-- .../commands/gen/types/types.handler.ts | 7 ++++ .../gen/types/types.integration.test.ts | 42 +++++++++++++++++++ .../legacy/commands/gen/types/types.shared.ts | 13 ++++++ .../commands/gen/types/types.unit.test.ts | 14 +++++++ .../legacy/shared/legacy-connect-errors.ts | 15 ++++--- .../shared/legacy-connect-errors.unit.test.ts | 32 ++++++++++++++ 8 files changed, 123 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 63d1e59386..9e313dc366 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,9 +216,6 @@ Inside Effect code, compose schemas through their Effect APIs: ## Code Quality -Never `git commit` or `git push` until lint and `types:check` have been run and passed for the change. Targeted unit/integration tests are not a substitute — CI Check code quality runs `pnpm check:all` (`types:check`, oxlint, oxfmt, knip). Before commit or push, from each changed TypeScript workspace run `pnpm types:check`, and from the repo root run `pnpm exec oxlint` (or `pnpm check:all`). If those fail, fix them before committing. -After every `git push` to a branch that has a PR, check GitHub CI for that PR (`gh pr checks` / `gh run list`) and report whether it is green. If it is not, diagnose and fix; do not leave a red PR as done. - Run repo-wide quality checks from the repository root with `pnpm check:all` or `pnpm fix:all`; these root scripts are the only quality entrypoints and delegate orchestration to Turbo. For package-local work, run `pnpm types:check` and the applicable package test scripts from the workspace you changed. Do not consider a task complete until all relevant scripts pass. Do not waive or defer failing checks in a changed workspace as "pre-existing". If a required check fails, fix it before closing the task. Only treat a failure as an external blocker when it cannot be resolved within the workspace, and in that case call it out explicitly. If you run a root quality command such as `pnpm check:all`, you own all failing checks it reports for the duration of the task, even if the failing files look unrelated. Do not leave the repository with unresolved failing checks after running the command. 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 a2834e56a2..09cd88d844 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -60,7 +60,9 @@ 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`). +(`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 @@ -77,8 +79,9 @@ 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. `--local` uses the published host port instead; `--db-url` must be -host-reachable. +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 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 2ae31ae6c6..da7c3dacfe 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -26,6 +26,7 @@ import { legacyReadDbToml, } from "../../../shared/legacy-db-config.toml-read.ts"; import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.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"; @@ -42,6 +43,7 @@ import { defaultSchemas, localDbContainerId, localDbPassword, + legacyGenTypesNetworkIdUnusedWarning, parseQueryTimeoutSeconds, } from "./types.shared.ts"; @@ -444,6 +446,11 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le } } + const networkIdOverride = legacyPflagStringValue(occurrences, "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( 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 27e0329b5d..50c5cbef97 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 @@ -1002,6 +1002,48 @@ describe("legacy gen types", () => { }); }); + 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"], + }); + + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); + + expect(generator.calls).toHaveLength(1); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("docker run --rm --network mycompose_default"), + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("npx --yes supabase gen types"), + }), + ); + }); + }); + + 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], + }); + + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); + + expect(out.messages.filter((message) => message.type === "warn")).toEqual([]); + }); + }); + 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({ 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 70dfd7a006..296abc491f 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -87,3 +87,16 @@ export function applyProbedSslMode( ...(sslrootcert !== undefined && sslrootcert.length > 0 ? { sslrootcert } : {}), }; } + +/** + * `--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 6df0f21183..b4225958d1 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 @@ -7,6 +7,7 @@ import { applyProbedSslMode, applyQueryTimeouts, defaultSchemas, + legacyGenTypesNetworkIdUnusedWarning, localDbPassword, parseQueryTimeoutSeconds, } from "./types.shared.ts"; @@ -162,6 +163,19 @@ describe("schema and password helpers", () => { }); }); +describe("legacyGenTypesNetworkIdUnusedWarning", () => { + it("names the unused flag and the docker run + npx workaround", () => { + const warning = legacyGenTypesNetworkIdUnusedWarning("mycompose_default"); + expect(warning).toContain("--network-id is unused"); + expect(warning).toContain("docker run --rm --network mycompose_default"); + expect(warning).toContain("npx --yes supabase gen types --db-url "); + }); + + it("uses a placeholder when the flag value is empty", () => { + expect(legacyGenTypesNetworkIdUnusedWarning("")).toContain("--network "); + }); +}); + describe("oxfmt binding pin", () => { it("stays on the oxfmt version postgrest-typegen resolves", () => { const cliPackageJson = fileURLToPath(new URL("../../../../../package.json", import.meta.url)); diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 6f46b679c8..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); } 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 ff938e4301..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,6 +85,21 @@ 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); @@ -601,6 +616,23 @@ 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( From 174ebfd243cd707a2d4e012e5cde4790e3d6a7b1 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 3 Sep 2026 12:40:09 +0200 Subject: [PATCH 14/14] fix(cli): fail closed on gen types TLS probe and warn pre-path --network-id A failed SSL probe no longer falls through to unverified TLS, and the unused --network-id warning now covers the cobra-normal persistent-flag position. Co-authored-by: Cursor --- .../legacy/commands/gen/types/SIDE_EFFECTS.md | 11 +-- .../types.generator.layer.integration.test.ts | 54 ++++++++++++++ .../gen/types/types.generator.layer.ts | 31 ++++---- .../commands/gen/types/types.generator.ts | 6 +- .../commands/gen/types/types.handler.ts | 6 +- .../gen/types/types.integration.test.ts | 61 ++++++---------- .../commands/gen/types/types.unit.test.ts | 72 +------------------ .../src/legacy/shared/legacy-temp-paths.ts | 2 - .../shared/legacy-temp-paths.unit.test.ts | 1 - 9 files changed, 109 insertions(+), 135 deletions(-) create mode 100644 apps/cli/src/legacy/commands/gen/types/types.generator.layer.integration.test.ts 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 09cd88d844..338386101f 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -71,11 +71,12 @@ 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 keeps the driver's TLS -default and lets the connection attempt surface the real error. 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. +`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 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 index 84261f5f33..a3680e1d5c 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.layer.ts @@ -6,9 +6,12 @@ import { sortGeneratorMetadata, } from "@supabase/postgrest-typegen/generation"; import { introspect } from "@supabase/postgrest-typegen/introspection"; -import { Duration, Effect, FileSystem, Layer, Path, Result } from "effect"; +import { Duration, Effect, FileSystem, Layer, Path } from "effect"; -import { legacyAcquirePgPool } from "../../../shared/legacy-db-connection.sql-pg.layer.ts"; +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"; @@ -44,18 +47,18 @@ const generate = ( Effect.scoped( Effect.gen(function* () { let conn = applyQueryTimeouts(input.conn, input.queryTimeoutSeconds); - // Remote DSNs without sslmode probe first (pg-meta did): no TLS → - // disable; TLS → require + the CA pin pg-meta got via - // PG_META_DB_SSL_ROOT_CERT. Probe failure keeps the driver default. - if (!input.isLocal && conn.sslmode === undefined) { - const probed = yield* sslProbe.requireSslForHost(conn.host, conn.port).pipe(Effect.result); - if (Result.isSuccess(probed)) { - if (!probed.success) { - conn = applyProbedSslMode(conn, false); - } else { - const sslrootcert = yield* pinProbedCaBundle(fs, path); - conn = applyProbedSslMode(conn, true, sslrootcert); - } + // 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); } } diff --git a/apps/cli/src/legacy/commands/gen/types/types.generator.ts b/apps/cli/src/legacy/commands/gen/types/types.generator.ts index ec5894e43a..0809e15658 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.generator.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.generator.ts @@ -2,6 +2,7 @@ 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"; @@ -44,7 +45,10 @@ interface LegacyGenTypesGeneratorShape { input: LegacyGenTypesGenerateInput, ) => Effect.Effect< string, - LegacyDbConnectError | LegacyGenTypesGenerateError | LegacyGenTypesMetadataError + | LegacyDbConnectError + | LegacyGenTypesGenerateError + | LegacyGenTypesMetadataError + | LegacyPgDeltaSslProbeError >; } 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 da7c3dacfe..bde8a5dae1 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -446,7 +446,11 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le } } - const networkIdOverride = legacyPflagStringValue(occurrences, "network-id"); + // 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)); } 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 50c5cbef97..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 @@ -1044,6 +1044,27 @@ describe("legacy gen types", () => { }); }); + 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], + }); + + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ dbUrl: Option.some(dbUrl) })).pipe( + Effect.provide(layer), + ); + + expect(generator.calls).toHaveLength(1); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("docker run --rm --network mycompose_default"), + }), + ); + }); + }); + 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({ @@ -2208,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"] }); @@ -2262,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), @@ -2607,18 +2600,6 @@ describe("legacy gen types", () => { }); }); - 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)); - }); - }); - it.live("rejects legacy positional non-typescript without an explicit lang flag", () => { const { layer } = setup({ args: ["gen", "types", "go"], 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 b4225958d1..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 @@ -8,7 +8,6 @@ import { applyQueryTimeouts, defaultSchemas, legacyGenTypesNetworkIdUnusedWarning, - localDbPassword, parseQueryTimeoutSeconds, } from "./types.shared.ts"; @@ -27,34 +26,7 @@ const BASE_CONN = { database: "postgres", }; -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; - } - } -} - describe("parseQueryTimeoutSeconds", () => { - it.effect("parses compound Go durations", () => - Effect.gen(function* () { - expect(yield* parseQueryTimeoutSeconds("15s")).toBe(15); - expect(yield* parseQueryTimeoutSeconds("1h")).toBe(3600); - expect(yield* parseQueryTimeoutSeconds("1m30s")).toBe(90); - expect(yield* parseQueryTimeoutSeconds("2h30m")).toBe(9000); - }), - ); - it.effect("accepts Go's bare 0 as disable", () => Effect.gen(function* () { expect(yield* parseQueryTimeoutSeconds("0")).toBe(0); @@ -76,36 +48,6 @@ describe("parseQueryTimeoutSeconds", () => { } }), ); - - it.effect("rejects an empty duration", () => - Effect.gen(function* () { - expectInvalidDuration(yield* parseQueryTimeoutSeconds(" ").pipe(Effect.exit), " "); - }), - ); - - it.effect("rejects a duration with a leading non-duration prefix", () => - Effect.gen(function* () { - expectInvalidDuration(yield* parseQueryTimeoutSeconds("x15s").pipe(Effect.exit), "x15s"); - }), - ); - - it.effect("rejects a duration with trailing junk", () => - Effect.gen(function* () { - expectInvalidDuration(yield* parseQueryTimeoutSeconds("15s30").pipe(Effect.exit), "15s30"); - }), - ); - - it.effect("rejects a string with no recognizable units", () => - Effect.gen(function* () { - expectInvalidDuration(yield* parseQueryTimeoutSeconds("abc").pipe(Effect.exit), "abc"); - }), - ); - - it.effect("rejects a negative duration", () => - Effect.gen(function* () { - expectInvalidDuration(yield* parseQueryTimeoutSeconds("-5s").pipe(Effect.exit), "-5s"); - }), - ); }); describe("applyQueryTimeouts", () => { @@ -151,26 +93,14 @@ describe("applyProbedSslMode", () => { }); }); -describe("schema and password helpers", () => { +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("reads the db password from the environment", () => { - expect(withEnv("SUPABASE_DB_PASSWORD", undefined, () => localDbPassword())).toBe("postgres"); - expect(withEnv("SUPABASE_DB_PASSWORD", "secret", () => localDbPassword())).toBe("secret"); - }); }); describe("legacyGenTypesNetworkIdUnusedWarning", () => { - it("names the unused flag and the docker run + npx workaround", () => { - const warning = legacyGenTypesNetworkIdUnusedWarning("mycompose_default"); - expect(warning).toContain("--network-id is unused"); - expect(warning).toContain("docker run --rm --network mycompose_default"); - expect(warning).toContain("npx --yes supabase gen types --db-url "); - }); - it("uses a placeholder when the flag value is empty", () => { expect(legacyGenTypesNetworkIdUnusedWarning("")).toContain("--network "); }); 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)), );