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 c7accf16f1..4ef5a89369 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -5,7 +5,8 @@ | 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 from config; required for `--local`, best-effort otherwise | +| `/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 | @@ -59,6 +60,11 @@ default 10s pg-delta probe timeout. | 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`) | @@ -95,6 +101,8 @@ Not applicable. ## Notes - Exactly one of `--local`, `--linked`, `--project-id`, or `--db-url` must be specified. +- With `--local`, a missing `supabase/config.toml` uses the embedded config defaults plus + shell and nested dotenv overrides, matching the legacy CLI. - `--lang` flag accepts `typescript` (default), `go`, `swift`, or `python`. Project-ref paths use the Management API for TypeScript, and use a project database host + temporary login role + pg-meta for other languages. 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 87113c6a42..3f090e6090 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,10 @@ 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"; import { legacyPoolerConfigFromConnectionString } from "../../../shared/legacy-db-config.parse.ts"; +import { + legacyApplyProjectEnv, + 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"; @@ -527,12 +531,12 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le yield* Effect.gen(function* () { if (flags.local) { - const loaded = yield* loadConfig(); - if (loaded === null) { - return yield* Effect.fail( - new Error("failed to load config: supabase/config.toml not found"), - ); - } + const config = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + yield* legacyApplyProjectEnv( + config.projectEnv, + Object.keys(config.projectEnv).filter((key) => key !== "SUPABASE_DB_PASSWORD"), + ); + const projectId = Option.getOrElse(config.projectId, () => path.basename(cliConfig.workdir)); const paths = legacyTempPaths(path, cliConfig.workdir); // Go resolves Config.Api.Image from the rest-version file only when @@ -540,7 +544,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le // (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. const restVersion = - loaded.config.db.major_version > 14 + config.majorVersion > 14 ? (yield* fs .readFileString(paths.restVersion) .pipe(Effect.orElseSucceed(() => ""))).trim() @@ -551,9 +555,8 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le .pipe(Effect.orElseSucceed(() => "")); const includedSchemas = ( - schemas.length > 0 ? schemas : defaultSchemas(loaded.config.api.schemas) + schemas.length > 0 ? schemas : defaultSchemas(config.apiSchemas) ).join(","); - const projectId = loaded.config.project_id ?? path.basename(cliConfig.workdir); yield* assertLocalDbRunning(projectId); yield* runPgMeta({ @@ -567,7 +570,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le host: "db", port: 5432, probeHost: legacyGetHostname(), - probePort: loaded.config.db.port, + probePort: config.port, networkMode: localNetworkId(projectId), includedSchemas, postgrestV9Compat: flags.postgrestV9Compat || forcedV9, @@ -637,5 +640,5 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le schemas.length > 0 ? schemas : schemasFromConfig(loaded?.config.api.schemas), false, ); - }).pipe(Effect.ensuring(telemetryState.flush)); + }).pipe(Effect.scoped, Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index 67015337f6..5d75f2caa9 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,7 +1,7 @@ import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import type { @@ -62,7 +62,12 @@ import type { import { legacyGenCommand } from "../gen.command.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; import { legacyGenTypes } from "./types.handler.ts"; -import { parseQueryTimeoutSeconds, resolvePgmetaImage } from "./types.shared.ts"; +import { + localDbContainerId, + localNetworkId, + parseQueryTimeoutSeconds, + resolvePgmetaImage, +} from "./types.shared.ts"; function writeConfig(workdir: string, contents: string) { const supabaseDir = join(workdir, "supabase"); @@ -2631,22 +2636,98 @@ describe("legacy gen types", () => { }, ); - it.live("fails local generation when supabase/config.toml is missing", () => { + it.live("generates locally with Go defaults when supabase/config.toml is missing", () => { const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-")); - const { layer } = setup({ workdir, skipConfig: true }); + const docker = captureDockerRun(); + const probes: Array<{ host: string; port: number }> = []; + const { layer, out, child } = 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; + }), + }), + }); return Effect.gen(function* () { - const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( - Effect.provide(layer), - Effect.exit, + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + const projectId = basename(workdir); + expect(child.spawned[0]).toEqual({ + command: "docker", + args: ["container", "inspect", localDbContainerId(projectId)], + }); + expect(child.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(out.stdoutText).toContain("generated"); + }); + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain( - "failed to load config: supabase/config.toml not found", - ); - } + it.live("honors local dotenv overrides when supabase/config.toml is missing", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-env-")); + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync( + join(supabaseDir, ".env"), + [ + "SUPABASE_PROJECT_ID=configless-env-project", + "SUPABASE_DB_PORT=55432", + "SUPABASE_DB_PASSWORD=remote-password", + "SUPABASE_API_SCHEMAS=private,graphql_public", + "SUPABASE_SERVICES_HOSTNAME=host.docker.internal", + "SUPABASE_INTERNAL_IMAGE_REGISTRY=mirror.example.com", + "", + ].join("\n"), + ); + const docker = captureDockerRun(); + const probes: Array<{ host: string; port: number }> = []; + const { layer, out, child } = setup({ + workdir, + skipConfig: true, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: (host, port) => + Effect.sync(() => { + probes.push({ host, port }); + return false; + }), + }), + }); + + return Effect.gen(function* () { + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + expect(child.spawned[0]).toEqual({ + 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(out.stdoutText).toContain("generated"); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 0e11809a56..e17dd5f5be 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -43,6 +43,7 @@ type EnvLookup = (name: string) => string | undefined; * and aborts the command rather than running against the default local database). */ interface LegacyDbTomlValues { + readonly projectEnv: Readonly>; /** * Resolves a `SUPABASE_*` env var with Go's precedence: shell env (non-empty) * wins, then the loaded project `.env*` files (non-empty), else undefined. @@ -51,6 +52,7 @@ interface LegacyDbTomlValues { * rather than `process.env` alone (e.g. `SUPABASE_EXPERIMENTAL_PG_DELTA`). */ readonly envLookup: (name: string) => string | undefined; + readonly apiSchemas: ReadonlyArray; /** `[db] port`, default 54322 (`packages/config/src/db.ts`). */ readonly port: number; /** `[db] shadow_port`, default 54320. */ @@ -169,6 +171,7 @@ const DEFAULT_PORT = 54322; const DEFAULT_SHADOW_PORT = 54320; const DEFAULT_MAJOR_VERSION = 17; const DEFAULT_PASSWORD = "postgres"; +const DEFAULT_API_SCHEMAS = ["public", "graphql_public"] as const; /** `[edge_runtime] deno_version` default (`config.toml` template). 2 → the current edge-runtime image. */ const DEFAULT_DENO_VERSION = 2; @@ -273,6 +276,7 @@ function legacyResolveValidatedRemoteProjectId( * `AutomaticEnv` — `config.go:635-637`), so the block value must beat the env override. */ const LEGACY_ENV_OVERRIDABLE_KEYS: ReadonlyArray = [ + "api.schemas", "db.port", "db.shadow_port", "db.major_version", @@ -470,6 +474,22 @@ function resolveConfigInt(value: unknown, lookup: EnvLookup): number | "absent" return "invalid"; } +function resolveStringSlice( + value: unknown, + fallback: ReadonlyArray, + lookup: EnvLookup, +): ReadonlyArray | undefined { + if (value === undefined) return fallback; + if (typeof value === "string") { + const expanded = legacyExpandEnv(value, lookup); + return expanded.length === 0 ? [] : expanded.split(","); + } + if (!Array.isArray(value) || !value.every((item): item is string => typeof item === "string")) { + return undefined; + } + return value.map((item) => legacyExpandEnv(item, lookup)); +} + /** * Replicates Go's `path.Join("supabase", pattern)` for a relative seed `sql_paths` * entry (`pkg/config/config.go:881-886`). Go's `path.Join` runs `path.Clean`, which @@ -603,9 +623,12 @@ export const legacyLoadProjectEnv = Effect.fnUntraced(function* ( * re-checks). The `acquireRelease` finalizer deletes only the keys it set when the * scope closes, so in-process test workers don't leak env between cases. */ -export const legacyApplyProjectEnv = (loaded: Record) => +export const legacyApplyProjectEnv = ( + loaded: Readonly>, + keys: ReadonlyArray = LEGACY_PROCESS_ENV_APPLY_KEYS, +) => Effect.forEach( - LEGACY_PROCESS_ENV_APPLY_KEYS, + keys, (key) => { const value = loaded[key]; if (value === undefined || process.env[key] !== undefined) { @@ -1834,9 +1857,22 @@ const readDbTomlCore = Effect.fnUntraced(function* ( apiRaw?.["auto_expose_new_tables"], lookup, ); + const apiSchemas = resolveStringSlice( + (remoteOverrideKeys.has("api.schemas") ? undefined : envOverride("SUPABASE_API_SCHEMAS")) ?? + apiRaw?.["schemas"], + DEFAULT_API_SCHEMAS, + lookup, + ); + if (apiSchemas === undefined) { + return yield* Effect.fail( + new LegacyDbConfigLoadError({ message: "failed to parse config: invalid api.schemas." }), + ); + } const values: LegacyDbTomlValues = { + projectEnv, envLookup: envOverride, + apiSchemas, port, shadowPort, password: passwordRaw !== undefined ? legacyExpandEnv(passwordRaw, lookup) : DEFAULT_PASSWORD, diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index 493f1f8276..4919c78ef8 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -929,11 +929,12 @@ describe("legacyReadDbToml", () => { ); }); - it.effect("keeps [api] auto_expose_new_tables tri-state None when absent", () => { - const dir = withConfig("[api]\n"); + it.effect("decodes empty api schemas while keeping auto_expose_new_tables absent", () => { + const dir = withConfig('[api]\nschemas = ""\n'); return read(dir).pipe( Effect.tap((v) => Effect.sync(() => { + expect(v.apiSchemas).toEqual([]); expect(Option.isNone(v.baseline.apiAutoExposeNewTables)).toBe(true); rmSync(dir, { recursive: true, force: true }); }),