diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index ce946eb4d5..1c3c6321b2 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -28,6 +28,16 @@ These commands exist in the TS CLI today but have no direct top-level equivalent Under the `SUPABASE_USE_PG_DELTA_NEXT=false` legacy opt-out the flag is accepted but has no effect, since the legacy edge-runtime engine does not emit coverage diagnostics. Default behavior (omitted flag) matches Go. +- `gen types` has two TS-only `--lang` values (the Go reference accepted only + `typescript`/`go`/`swift`/`python`): `json` emits the language-neutral + `GeneratorMetadata` introspection document (the `@supabase/postgrest-typegen` + contract, for third-party type generators), and `dart` pipes that document + through the `supabase_typegen` Dart package on the host, requiring the Dart + SDK on PATH and `supabase_typegen` as a dev dependency of the current + project. `dart` generates one schema per run and therefore accepts at most + one `--schema`. Both require a pg-meta image that ships a `json` output for + `PG_META_GENERATE_TYPES` (supabase/postgres-meta#1110, stacked on the #1084 + postgrest-typegen extraction). - `db push` has a TS-only `--skip-vault` flag. It applies migrations without resolving or updating `[db.vault]` secrets; default behavior still matches Go. - Every legacy command that resolves a linked project ref for its own database 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..978ea8e4e6 100644 --- a/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/types/SIDE_EFFECTS.md @@ -51,11 +51,19 @@ config for that ref to build the fallback connection (the saved workdir | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------- | | `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 | +| `dart run supabase_typegen --input - --output - --schema ` | `--lang dart`, after the pg-meta container exits successfully | turn pg-meta's json metadata into Dart source | 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. +For `--lang dart` the pg-meta container runs the `json` generator; its stdout is +collected instead of printed and piped to the Dart typegen's stdin. The Dart SDK +must be on PATH and `supabase_typegen` must be a dev dependency of the current +project. The typegen generates one schema per run, so `--lang dart` accepts at +most one `--schema` (defaulting to `public`) and fails otherwise instead of +silently dropping schemas. + ## Environment Variables | Variable | Purpose | Required? | @@ -108,7 +116,8 @@ Not applicable. - With `--local`, a missing `supabase/config.toml` uses the embedded config defaults plus shell and nested dotenv overrides, matching the legacy CLI. - **Sanctioned intentional divergence (CLI-1988 parity ruling):** - `--lang` accepts `typescript` (default), `go`, `swift`, or `python`. Project-ref paths + `--lang` accepts `typescript` (default), `go`, `swift`, `python`, `dart`, or `json` + (the last two are TS-only, see `docs/go-cli-divergences.md`). 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 login-role credentials, preview-branch fallback) for the other languages. The old Go diff --git a/apps/cli/src/legacy/commands/gen/types/types.command.ts b/apps/cli/src/legacy/commands/gen/types/types.command.ts index 113a3a5413..f57e73508a 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.command.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.command.ts @@ -6,7 +6,7 @@ import { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; import { legacyGenTypes } from "./types.handler.ts"; import { legacyGenTypesRuntimeLayer } from "./types.layers.ts"; -const LANG_VALUES = ["typescript", "go", "swift", "python"] as const; +const LANG_VALUES = ["typescript", "go", "swift", "python", "dart", "json"] as const; const SWIFT_ACCESS_CONTROL_VALUES = ["internal", "public"] as const; const config = { @@ -77,6 +77,10 @@ export const legacyGenTypesCommand = Command.make("types", commandConfig).pipe( command: "supabase gen types --db-url 'postgresql://...' --schema public --schema auth", description: "Generate types from a database URL", }, + { + command: "supabase gen types --local --lang=dart", + description: "Generate Dart types from the local dev database", + }, ]), Command.withHandler((flags) => legacyGenTypes(flags).pipe( 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 1bab0dda3d..639174b4f2 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -1,5 +1,5 @@ import { loadProjectConfig } from "@supabase/config"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; import { LegacyDnsResolverFlag, @@ -46,6 +46,9 @@ import { LegacyGenTypesNetworkError, LegacyGenTypesUnexpectedStatusError } from import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { + DART_TYPEGEN_ARGS, + DART_TYPEGEN_COMMAND, + DART_TYPEGEN_UNAVAILABLE_MESSAGE, defaultSchemas, buildPostgresUrl, localDbContainerId, @@ -257,6 +260,10 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const schemas = flags.schema; const lang = flags.lang; const swiftAccessControl = flags.swiftAccessControl; + // Dart types are produced by piping pg-meta's language-neutral `json` + // generator metadata through the supabase_typegen package, so the container + // always runs the json generator for `--lang dart`. + const pgMetaLang = lang === "dart" ? "json" : lang; const loadConfig = () => loadProjectConfig(cliConfig.workdir, { goViperCompat: true }); const loadConfigForRef = (projectRef: string) => @@ -416,7 +423,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le `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=${pgMetaLang}`, `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)}`, @@ -456,11 +463,18 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le stderr: "pipe", }); + // For `--lang dart` the container emits the json generator + // metadata, which is collected and piped into the Dart typegen + // after the run instead of being printed. let stderrText = ""; - const [exitCode] = yield* Effect.all( + const [exitCode, stdoutText] = yield* Effect.all( [ child.exitCode.pipe(Effect.map(Number)), - forwardByteStream(child.stdout, (text) => output.raw(text, "stdout")), + lang === "dart" + ? collectByteStream(child.stdout) + : forwardByteStream(child.stdout, (text) => output.raw(text, "stdout")).pipe( + Effect.map(() => ""), + ), forwardByteStream(child.stderr, (text) => Effect.sync(() => { stderrText += text; @@ -469,7 +483,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ], { concurrency: "unbounded" }, ); - return { exitCode, stderrText }; + return { exitCode, stderrText, stdoutText }; }); const runTarget = (conn: LegacyPgConnInput) => @@ -481,6 +495,18 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le probePort: conn.port, }); + // The Dart typegen generates one schema per run; without this guard a + // multi-schema selection would silently come out as only one schema. + const dartSchemas = input.includedSchemas.split(",").filter((schema) => schema.length > 0); + if (lang === "dart" && dartSchemas.length > 1) { + return yield* Effect.fail( + new Error( + `--lang dart generates one schema per run, but got: ${input.includedSchemas}. ` + + "Pass a single --schema and run the command once per schema.", + ), + ); + } + const result = input.poolerFallback === undefined ? yield* buildRun(input) @@ -498,6 +524,46 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le if (result.exitCode !== 0) { return yield* Effect.fail(new Error(`error running container: exit ${result.exitCode}`)); } + + if (lang === "dart") { + yield* runDartTypegen(result.stdoutText, dartSchemas[0] ?? "public"); + } + }), + ); + + // Pipes the json generator metadata into the supabase_typegen package on + // the host: the generated Dart code arrives on stdout like every other + // language, and the package's own summary line stays on stderr. + const runDartTypegen = (metadataJson: string, schema: string) => + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner + .spawn( + ChildProcess.make(DART_TYPEGEN_COMMAND, [...DART_TYPEGEN_ARGS, "--schema", schema], { + stdin: Stream.succeed(new TextEncoder().encode(metadataJson)), + stdout: "pipe", + stderr: "pipe", + }), + ) + .pipe(Effect.mapError(() => new Error(DART_TYPEGEN_UNAVAILABLE_MESSAGE))); + + const [exitCode] = yield* Effect.all( + [ + child.exitCode.pipe(Effect.map(Number)), + forwardByteStream(child.stdout, (text) => output.raw(text, "stdout")), + forwardByteStream(child.stderr, (text) => output.raw(text, "stderr")), + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) { + return yield* Effect.fail( + new Error( + `error running the supabase_typegen package: exit ${exitCode}. ` + + "If the package could not be resolved, add supabase_typegen as a dev " + + "dependency of the current project to generate Dart types.", + ), + ); + } }), ); 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 ba4950784c..3413f25e02 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 @@ -63,6 +63,8 @@ import { legacyGenCommand } from "../gen.command.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; import { legacyGenTypes } from "./types.handler.ts"; import { + DART_TYPEGEN_ARGS, + DART_TYPEGEN_COMMAND, localDbContainerId, localNetworkId, parseQueryTimeoutSeconds, @@ -1213,6 +1215,202 @@ describe("legacy gen types", () => { }), ); + it.live("passes json straight through to pg-meta for --lang json", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const docker = captureDockerRun(); + const { layer, out } = setup({ + args: [ + "gen", + "types", + "--db-url", + `postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, + "--lang", + "json", + ], + childStdout: ['{"tables":[],"columns":[]}'], + onSpawn: docker.onSpawn, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + lang: "json", + }), + ).pipe(Effect.provide(layer)), + ); + + expect(docker.env.has("PG_META_GENERATE_TYPES=json")).toBe(true); + expect(out.stdoutText).toContain('{"tables":[],"columns":[]}'); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("pipes pg-meta's json metadata through the Dart typegen for --lang dart", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + // First spawn is the pg-meta container emitting the json metadata, + // second is the host `dart run supabase_typegen` invocation emitting + // the generated code. + const child = mockSequentialChildProcessSpawner([ + { exitCode: 0, stdout: ['{"tables":[],"columns":[]}'] }, + { exitCode: 0, stdout: ["// generated dart"] }, + ]); + const { layer, out } = setup({ + args: [ + "gen", + "types", + "--db-url", + `postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, + "--lang", + "dart", + ], + childLayer: child.layer, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + lang: "dart", + }), + ).pipe(Effect.provide(layer)), + ); + + expect(child.spawned).toHaveLength(2); + const dockerRun = child.spawned[0]; + expect(dockerRun?.args).toContain("PG_META_GENERATE_TYPES=json"); + const dart = child.spawned[1]; + expect(dart?.command).toBe(DART_TYPEGEN_COMMAND); + expect(dart?.args).toEqual([...DART_TYPEGEN_ARGS, "--schema", "public"]); + // The metadata is piped to the Dart typegen, never printed; only the + // generated code reaches stdout. + expect(out.stdoutText).toContain("// generated dart"); + expect(out.stdoutText).not.toContain('"columns"'); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("fails with an actionable error when the Dart typegen exits non-zero", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const child = mockSequentialChildProcessSpawner([ + { exitCode: 0, stdout: ['{"tables":[],"columns":[]}'] }, + { exitCode: 65, stderr: ["Could not find package `supabase_typegen`"] }, + ]); + const { layer } = setup({ + args: [ + "gen", + "types", + "--db-url", + `postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, + "--lang", + "dart", + ], + childLayer: child.layer, + }); + + const exit = await Effect.runPromiseExit( + legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + lang: "dart", + }), + ).pipe(Effect.provide(layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(String(exit)).toContain("error running the supabase_typegen package: exit 65"); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("forwards a single non-default schema to the Dart typegen", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const child = mockSequentialChildProcessSpawner([ + { exitCode: 0, stdout: ['{"tables":[],"columns":[]}'] }, + { exitCode: 0, stdout: ["// generated dart"] }, + ]); + const { layer } = setup({ + args: [ + "gen", + "types", + "--db-url", + `postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, + "--lang", + "dart", + "--schema", + "sales", + ], + childLayer: child.layer, + }); + + await Effect.runPromise( + legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + lang: "dart", + schema: ["sales"], + }), + ).pipe(Effect.provide(layer)), + ); + + const dart = child.spawned[1]; + expect(dart?.args).toEqual([...DART_TYPEGEN_ARGS, "--schema", "sales"]); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + + it.live("rejects --lang dart with more than one schema", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const child = mockSequentialChildProcessSpawner([]); + const { layer } = setup({ + args: [ + "gen", + "types", + "--db-url", + `postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, + "--lang", + "dart", + "--schema", + "public", + "--schema", + "sales", + ], + childLayer: child.layer, + }); + + const exit = await Effect.runPromiseExit( + legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + lang: "dart", + schema: ["public", "sales"], + }), + ).pipe(Effect.provide(layer)), + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(String(exit)).toContain("--lang dart generates one schema per run"); + // The guard fires before any container is spawned. + expect(child.spawned).toHaveLength(0); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + it.live("allows --postgrest-v9-compat together with --db-url", () => Effect.tryPromise({ try: () => 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 4480a03ada..a123b2c5bd 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -42,6 +42,30 @@ export function defaultSchemas(extraSchemas: ReadonlyArray = []) { return [...new Set(["public", ...extraSchemas])]; } +/** + * `--lang dart` pipes pg-meta's `json` generator metadata through the + * `supabase_typegen` Dart package. The host's Dart SDK runs the package from + * the current working directory, so it must be declared as a (dev) dependency + * of the project the command runs in. The invocation and its stdin/stdout + * contract (`--input -` reads the metadata document from stdin, `--output -` + * writes the generated code to stdout) are pinned here so the handler and + * tests share one definition; the handler appends `--schema ` because + * the package generates one schema per run. + */ +export const DART_TYPEGEN_COMMAND = "dart"; + +export const DART_TYPEGEN_ARGS = [ + "run", + "supabase_typegen", + "--input", + "-", + "--output", + "-", +] as const; + +export const DART_TYPEGEN_UNAVAILABLE_MESSAGE = + "Generating Dart types requires the Dart SDK on PATH. Install it from https://dart.dev/get-dart and add the supabase_typegen package as a dev dependency of the current project."; + export function parseQueryTimeoutSeconds( raw: string, ): Effect.Effect {