diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index c1ac3b2c96..2c4fa4e7df 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -47,13 +47,14 @@ This tracker is command-focused, but root global flag drift is large enough to n These commands exist in the TS CLI today but have no direct top-level equivalent in the old Go CLI reference. -| TS command | TS path | Notes | -| ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | -| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | -| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | -| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | -| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| TS command | TS path | Notes | +| ----------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dev` | `planned` | Reserved for a TS-native long-running local development workflow command that watches files and orchestrates subcommands. Track this as TS-only unless a direct Go equivalent emerges. | +| `logs` | [`../src/next/commands/logs/logs.command.ts`](../src/next/commands/logs/logs.command.ts) | Streams local stack logs. No top-level `logs` command exists in the old Go CLI reference. | +| `api` | [`../src/next/commands/platform/api.command.ts`](../src/next/commands/platform/api.command.ts) | Low-level Management API client. It supersedes the old generated tree with explicit discovery via `supabase api routes` and execution via `supabase api request [--method ]`. | +| `stack` | [`../src/next/cli/root.ts`](../src/next/cli/root.ts) | TS-only local runtime namespace exposing `stack start`, `stack stop`, `stack status`, `stack list`, and `stack update`. Top-level `start`, `stop`, and `status` remain aliases. | +| `branches switch` | [`../src/next/commands/branches/switch/switch.command.ts`](../src/next/commands/branches/switch/switch.command.ts) | No direct Go equivalent. Updates local active-branch state so subsequent commands target the selected branch. | +| `gen tanstack-db` | [`../src/legacy/commands/gen/tanstack-db/`](../src/legacy/commands/gen/tanstack-db/tanstack-db.command.ts) | No Go equivalent. Generates a Zod + TanStack DB collection file from the project's Postgres schema (`--local`/`--linked`/`--project-id`), inspired by the `supabase/supabase` `ui-library` tanstack-db registry block. Prints to stdout like `gen types`. | ## Quick Start diff --git a/apps/cli/src/legacy/commands/gen/gen.command.ts b/apps/cli/src/legacy/commands/gen/gen.command.ts index c7a91d01c1..5dcee48a9e 100644 --- a/apps/cli/src/legacy/commands/gen/gen.command.ts +++ b/apps/cli/src/legacy/commands/gen/gen.command.ts @@ -3,6 +3,7 @@ import { legacyGenTypesCommand } from "./types/types.command.ts"; import { legacyGenSigningKeyCommand } from "./signing-key/signing-key.command.ts"; import { legacyGenBearerJwtCommand } from "./bearer-jwt/bearer-jwt.command.ts"; import { legacyGenKeysCommand } from "./keys/keys.command.ts"; +import { legacyGenTanstackDbCommand } from "./tanstack-db/tanstack-db.command.ts"; export const legacyGenCommand = Command.make("gen").pipe( Command.withDescription("Run code generation tools."), @@ -12,5 +13,6 @@ export const legacyGenCommand = Command.make("gen").pipe( legacyGenSigningKeyCommand, legacyGenBearerJwtCommand, legacyGenKeysCommand, + legacyGenTanstackDbCommand, ]), ); diff --git a/apps/cli/src/legacy/commands/gen/legacy-gen-schemas.ts b/apps/cli/src/legacy/commands/gen/legacy-gen-schemas.ts new file mode 100644 index 0000000000..28ce259849 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/legacy-gen-schemas.ts @@ -0,0 +1,8 @@ +/** + * Shared by `gen types` and `gen tanstack-db`: both let the user select + * schemas via `--schema`/`-s` and fall back to the project's configured + * `api.schemas` (plus `public`) when no flag value is given. + */ +export function defaultSchemas(extraSchemas: ReadonlyArray = []) { + return [...new Set(["public", ...extraSchemas])]; +} diff --git a/apps/cli/src/legacy/commands/gen/tanstack-db/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/tanstack-db/SIDE_EFFECTS.md new file mode 100644 index 0000000000..5491b798b4 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/tanstack-db/SIDE_EFFECTS.md @@ -0,0 +1,71 @@ +# `supabase gen tanstack-db` + +> TS-only command — no Go CLI equivalent. See `docs/go-cli-porting-status.md` → "TS-only Commands". + +## Files Read + +| Path | Format | When | +| -------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` / `--project-id` / the implicit linked fallback resolves a ref | +| `/supabase/config.toml` | TOML | `--local` (required — fails if missing); best-effort otherwise to resolve default `--schema` values | + +## Files Written + +| Path | Format | When | +| ---- | ------ | ---- | +| — | — | — | + +No files are written. The generated TanStack DB + Zod file is printed to stdout; the user redirects it to a file themselves (e.g. `supabase gen tanstack-db --linked > lib/tanstack-db.ts`), matching `supabase gen types`. + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | --------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/database/openapi` | Bearer token | none | `definitions` (PostgREST OpenAPI table definitions) | + +Called once per requested `--schema` value (default `public`) for `--linked`, `--project-id`, and the implicit linked-project fallback. `--local` does not call the Management API — instead it calls the local stack's own PostgREST gateway directly (see below). + +## Local Stack Requests + +| Method | URL | Headers | When | +| ------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `GET` | `http://127.0.0.1:/rest/v1/` | `apikey`, `Accept-Profile: `, and `Authorization: Bearer ` unless the key is a new-style `sb_…` key | `--local`, once per requested schema | + +The `apikey`/bearer value is `auth.publishable_key` (falling back to `auth.anon_key`, then the local stack's default publishable key) from `supabase/config.toml`. + +## Environment Variables + +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` / `--project-id` / implicit fallback | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | + +## Exit Codes + +| Code | Condition | +| ---- | --------------------------------------------------------------------------- | +| `0` | success — generated file printed to stdout | +| `1` | no target specified (must use one of `--local`, `--linked`, `--project-id`) | +| `1` | mutually exclusive flags combined (`--local`/`--linked`/`--project-id`) | +| `1` | `supabase/config.toml` not found (`--local`) | +| `1` | local stack unreachable (`--local`, "supabase start is not running.") | +| `1` | Management API error (network or non-2xx status) | +| `1` | a table has no primary key column | +| `1` | no tables found in the selected schema(s) | + +## Output + +### `--output-format text` (only supported mode) + +Prints the generated TypeScript file (Zod schemas + TanStack DB collections, one pair per table) to stdout. Diagnostics, if any, go to stderr. + +### `--output-format json` / `stream-json` + +Not applicable — this is a TS-only command and does not model structured output modes; it always emits the raw generated file to stdout regardless of `--output-format`. + +## Notes + +- Exactly one of `--local`, `--linked`, or `--project-id` may be specified; when none is given, the linked project is used as a fallback (same resolution order as `gen types`), minus `--db-url`, which has no PostgREST endpoint to introspect. +- `--schema` / `-s` accepts a comma-separated list, repeatable; defaults to the project's configured `api.schemas` (always includes `public`). One OpenAPI document is fetched per schema and the table sets are merged; a table name present in more than one requested schema is last-write-wins. +- A table's primary key is detected from PostgREST's `Note:\nThis is a Primary Key.` OpenAPI property description, falling back to a literal `id` column. A composite primary key produces a `keys` array with all of its columns. +- The generated file imports from `zod`, `@tanstack/db`, `@supabase-labs/tanstack-db`, and `@supabase/supabase-js`, and expects `SUPABASE_URL` and `SUPABASE_ANON_KEY` environment variables — none of these are installed or configured by this command. diff --git a/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.command.ts b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.command.ts new file mode 100644 index 0000000000..7734bc552e --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.command.ts @@ -0,0 +1,59 @@ +import { Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; +import { legacyGenTanstackDb } from "./tanstack-db.handler.ts"; +import { legacyGenTanstackDbRuntimeLayer } from "./tanstack-db.layers.ts"; + +const config = { + local: Flag.boolean("local").pipe( + Flag.withDescription("Generate the TanStack DB file from the local dev database."), + ), + linked: Flag.boolean("linked").pipe( + Flag.withDescription("Generate the TanStack DB file from the linked project."), + ), + projectId: Flag.string("project-id").pipe( + Flag.withDescription("Generate the TanStack DB file from a project ID."), + Flag.optional, + ), + schema: Flag.string("schema").pipe( + Flag.withAlias("s"), + Flag.withDescription("Comma separated list of schema to include."), + Flag.atLeast(0), + Flag.mapTryCatch( + (rawValues) => legacyParseSchemaFlags(rawValues), + (err) => (err instanceof Error ? err.message : String(err)), + ), + ), +} as const; + +export type LegacyGenTanstackDbFlags = CliCommand.Command.Config.Infer; + +export const legacyGenTanstackDbCommand = Command.make("tanstack-db", config).pipe( + Command.withDescription( + "Generate a Zod + TanStack DB collection file from your Postgres schema.", + ), + Command.withShortDescription("Generate a TanStack DB file from your Postgres schema"), + Command.withExamples([ + { + command: "supabase gen tanstack-db --local", + description: "Generate a TanStack DB file from the local dev database", + }, + { + command: "supabase gen tanstack-db --linked --schema public --schema private", + description: "Generate a TanStack DB file from the linked project with specific schemas", + }, + { + command: "supabase gen tanstack-db --project-id abc-def-123", + description: "Generate a TanStack DB file from a project ID", + }, + ]), + Command.withHandler((flags) => + legacyGenTanstackDb(flags).pipe( + withLegacyCommandInstrumentation({ flags, safeFlags: ["project-id"], config }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyGenTanstackDbRuntimeLayer), +); diff --git a/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.errors.ts b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.errors.ts new file mode 100644 index 0000000000..61b477d4a8 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.errors.ts @@ -0,0 +1,45 @@ +import { Data } from "effect"; + +export class LegacyGenTanstackDbNetworkError extends Data.TaggedError( + "LegacyGenTanstackDbNetworkError", +)<{ + readonly message: string; +}> {} + +export class LegacyGenTanstackDbUnexpectedStatusError extends Data.TaggedError( + "LegacyGenTanstackDbUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +export class LegacyGenTanstackDbDecodeError extends Data.TaggedError( + "LegacyGenTanstackDbDecodeError", +)<{ + readonly message: string; +}> {} + +export class LegacyGenTanstackDbLocalStackNotRunningError extends Data.TaggedError( + "LegacyGenTanstackDbLocalStackNotRunningError", +)<{ + readonly message: string; +}> {} + +export class LegacyGenTanstackDbNoTablesError extends Data.TaggedError( + "LegacyGenTanstackDbNoTablesError", +)<{ + readonly message: string; +}> {} + +export class LegacyGenTanstackDbUnsafeNameError extends Data.TaggedError( + "LegacyGenTanstackDbUnsafeNameError", +)<{ + readonly message: string; +}> {} + +export class LegacyGenTanstackDbNoPrimaryKeyError extends Data.TaggedError( + "LegacyGenTanstackDbNoPrimaryKeyError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.generators.ts b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.generators.ts new file mode 100644 index 0000000000..48c9c92c7c --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.generators.ts @@ -0,0 +1,293 @@ +import { Effect, Schema } from "effect"; +import { + LegacyGenTanstackDbDecodeError, + LegacyGenTanstackDbNoPrimaryKeyError, + LegacyGenTanstackDbNoTablesError, + LegacyGenTanstackDbUnsafeNameError, +} from "./tanstack-db.errors.ts"; + +/** + * Decodes the PostgREST OpenAPI/Swagger document returned by the Management API's + * `database/openapi` endpoint (linked/project-id) or by the local stack's + * `GET /rest/v1/` (--local). Only the `definitions` field is used — everything + * else (`swagger`, `info`, `paths`, …) is ignored. + */ +const legacyOpenApiPropertyItems = Schema.Struct({ + type: Schema.optionalKey(Schema.String), + format: Schema.optionalKey(Schema.String), +}); + +const legacyOpenApiProperty = Schema.Struct({ + type: Schema.optionalKey(Schema.String), + format: Schema.optionalKey(Schema.String), + description: Schema.optionalKey(Schema.String), + enum: Schema.optionalKey(Schema.Array(Schema.String)), + items: Schema.optionalKey(legacyOpenApiPropertyItems), +}); + +const legacyOpenApiDefinition = Schema.Struct({ + properties: Schema.optionalKey(Schema.Record(Schema.String, legacyOpenApiProperty)), + required: Schema.optionalKey(Schema.Array(Schema.String)), +}); + +const legacyOpenApiDocument = Schema.Struct({ + definitions: Schema.optionalKey(Schema.Record(Schema.String, legacyOpenApiDefinition)), +}); + +type LegacyOpenApiProperty = typeof legacyOpenApiProperty.Type; +export type LegacyOpenApiDefinition = typeof legacyOpenApiDefinition.Type; + +/** Decodes a raw PostgREST OpenAPI JSON body into its table `definitions`. */ +export function legacyDecodeOpenApiDefinitions( + raw: unknown, +): Effect.Effect, LegacyGenTanstackDbDecodeError> { + return Schema.decodeUnknownEffect(legacyOpenApiDocument)(raw).pipe( + Effect.map((document) => document.definitions ?? {}), + Effect.catch((cause) => + Effect.fail( + new LegacyGenTanstackDbDecodeError({ + message: `failed to decode database schema: ${String(cause)}`, + }), + ), + ), + ); +} + +/** + * Merges per-schema OpenAPI `definitions` maps (one document is fetched per + * requested `--schema` value) into a single table map. A table name that + * exists in more than one requested schema is last-write-wins, in schema + * request order — an accepted edge case, not a modeled conflict. + */ +export function legacyMergeOpenApiDefinitions( + documents: ReadonlyArray>, +): Record { + const merged: Record = {}; + for (const document of documents) { + for (const [tableName, definition] of Object.entries(document)) { + merged[tableName] = definition; + } + } + return merged; +} + +// --------------------------------------------------------------------------- +// Name sanitizers — ported from the `tanstack-db` block registry +// (supabase/supabase apps/ui-library/app/api/registry/tanstack-db/utils.ts), +// adapted to fail with a typed error instead of a bare `Error`. +// --------------------------------------------------------------------------- + +const VALID_IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +function legacySanitizeIdentifier(name: string): string { + let sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_"); + if (/^[0-9]/.test(sanitized)) sanitized = `_${sanitized}`; + sanitized = sanitized.replace(/_+/g, "_").replace(/^_+|_+$/g, "") || "_"; + + if (!VALID_IDENTIFIER_RE.test(sanitized)) { + throw new LegacyGenTanstackDbUnsafeNameError({ + message: `cannot safely map name "${name}" to a valid identifier: names must contain only letters, digits, and underscores`, + }); + } + return sanitized; +} + +function legacyToPascalCase(value: string): string { + return value + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); +} + +function legacyToCamelCase(value: string): string { + const pascal = legacyToPascalCase(value); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +/** + * PostgREST embeds `Note:\nThis is a Primary Key.` in a primary-key + * column's OpenAPI `description`. Falls back to a column literally named + * `id` when no column carries that hint. + */ +function legacyFindPrimaryKeys( + properties: Record, +): ReadonlyArray { + const primaryKeys = Object.entries(properties) + .filter(([, prop]) => prop.description?.toLowerCase().includes("primary key") === true) + .map(([name]) => name); + + if (primaryKeys.length === 0 && properties["id"] !== undefined) { + return ["id"]; + } + return primaryKeys; +} + +function legacyOpenApiItemTypeToZod(item: { type?: string; format?: string } | undefined): string { + if (item === undefined) return "z.unknown()"; + switch (item.type) { + case "string": + return item.format === "uuid" ? "z.string().uuid()" : "z.string()"; + case "integer": + return "z.number().int()"; + case "number": + return "z.number()"; + case "boolean": + return "z.boolean()"; + default: + return "z.unknown()"; + } +} + +function legacyOpenApiTypeToZod(prop: LegacyOpenApiProperty, isRequired: boolean): string { + let zodType: string; + switch (prop.type) { + case "string": + if (prop.format === "uuid") { + zodType = "z.string().uuid()"; + } else if (prop.format === "date-time" || prop.format === "timestamp with time zone") { + zodType = "z.string()"; + } else if (prop.enum !== undefined && prop.enum.length > 0) { + const enumValues = prop.enum.map((value) => `'${value.replaceAll("'", "\\'")}'`).join(", "); + zodType = `z.enum([${enumValues}])`; + } else { + zodType = "z.string()"; + } + break; + case "integer": + zodType = "z.number().int()"; + break; + case "number": + zodType = "z.number()"; + break; + case "boolean": + zodType = "z.boolean()"; + break; + case "array": + zodType = `z.array(${legacyOpenApiItemTypeToZod(prop.items)})`; + break; + case "object": + zodType = "z.record(z.unknown())"; + break; + default: + zodType = "z.unknown()"; + } + return isRequired ? zodType : `${zodType}.nullable()`; +} + +// --------------------------------------------------------------------------- +// Content generation — one Zod schema + one TanStack DB collection per table, +// combined into a single generated file. +// --------------------------------------------------------------------------- + +function legacyGenerateSchemaLines( + tableName: string, + definition: LegacyOpenApiDefinition, +): Array { + const safeTableId = legacySanitizeIdentifier(tableName); + const typeName = legacyToPascalCase(safeTableId); + const schemaName = `${legacyToCamelCase(safeTableId)}Schema`; + const properties = definition.properties ?? {}; + const required = definition.required ?? []; + + const lines = [`// ${typeName} schema`, `export const ${schemaName} = z.object({`]; + for (const [propName, prop] of Object.entries(properties)) { + const zodType = legacyOpenApiTypeToZod(prop, required.includes(propName)); + lines.push(` ${JSON.stringify(propName)}: ${zodType},`); + } + lines.push("})", "", `export type ${typeName} = z.infer`); + return lines; +} + +function legacyGenerateCollectionLines( + tableName: string, + definition: LegacyOpenApiDefinition, +): Array { + const safeTableId = legacySanitizeIdentifier(tableName); + const collectionName = `${legacyToCamelCase(safeTableId)}Collection`; + const schemaName = `${legacyToCamelCase(safeTableId)}Schema`; + const properties = definition.properties ?? {}; + const primaryKeys = legacyFindPrimaryKeys(properties); + + if (primaryKeys.length === 0) { + throw new LegacyGenTanstackDbNoPrimaryKeyError({ + message: `table "${tableName}" has no primary key columns; TanStack DB collections require at least one primary key`, + }); + } + + const keysLiteral = `[${primaryKeys.map((key) => JSON.stringify(key)).join(", ")}]`; + + return [ + `export const ${collectionName} = createCollection(supabaseCollectionOptions({`, + ` tableName: ${JSON.stringify(tableName)},`, + ` schema: ${schemaName},`, + ` keys: ${keysLiteral},`, + " supabase,", + " realtime: true,", + "}))", + ]; +} + +const LEGACY_TANSTACK_DB_FILE_HEADER = [ + "// Generated by `supabase gen tanstack-db`. Do not edit by hand — rerun the", + "// command to regenerate after a schema change.", + 'import { z } from "zod";', + 'import { createClient } from "@supabase/supabase-js";', + 'import { createCollection } from "@tanstack/db";', + 'import { supabaseCollectionOptions } from "@supabase-labs/tanstack-db";', + "", + "// Requires SUPABASE_URL and SUPABASE_ANON_KEY to be set.", + "const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);", +]; + +function legacyGenerateTanstackDbFileContent( + definitions: Record, +): string { + const tables = Object.entries(definitions).filter(([name]) => !name.startsWith("_")); + if (tables.length === 0) { + throw new LegacyGenTanstackDbNoTablesError({ + message: "no tables found in the selected schema(s)", + }); + } + + const body = tables.flatMap(([tableName, definition]) => [ + "", + ...legacyGenerateSchemaLines(tableName, definition), + "", + ...legacyGenerateCollectionLines(tableName, definition), + ]); + + return [...LEGACY_TANSTACK_DB_FILE_HEADER, ...body, ""].join("\n"); +} + +/** + * Effectful entry point: runs the pure generator and maps any of the three + * modeled failure modes (unsafe name, missing primary key, no tables) to + * their typed error. Any other thrown value would be a generator bug, not a + * modeled failure — it is still surfaced as `LegacyGenTanstackDbUnsafeNameError` + * rather than defect, since the generator never throws anything else on + * purpose. + */ +export function legacyGenerateTanstackDbFile( + definitions: Record, +): Effect.Effect< + string, + | LegacyGenTanstackDbUnsafeNameError + | LegacyGenTanstackDbNoPrimaryKeyError + | LegacyGenTanstackDbNoTablesError +> { + return Effect.try({ + try: () => legacyGenerateTanstackDbFileContent(definitions), + catch: (cause) => { + if ( + cause instanceof LegacyGenTanstackDbUnsafeNameError || + cause instanceof LegacyGenTanstackDbNoPrimaryKeyError || + cause instanceof LegacyGenTanstackDbNoTablesError + ) { + return cause; + } + return new LegacyGenTanstackDbUnsafeNameError({ + message: cause instanceof Error ? cause.message : String(cause), + }); + }, + }); +} diff --git a/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.generators.unit.test.ts b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.generators.unit.test.ts new file mode 100644 index 0000000000..07de287037 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.generators.unit.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; +import { + legacyDecodeOpenApiDefinitions, + legacyGenerateTanstackDbFile, + legacyMergeOpenApiDefinitions, +} from "./tanstack-db.generators.ts"; + +function todosDefinition() { + return { + todos: { + properties: { + id: { type: "integer", description: "Note:\nThis is a Primary Key." }, + title: { type: "string" }, + status: { type: "string", enum: ["open", "closed"] }, + created_at: { type: "string", format: "timestamp with time zone" }, + tags: { type: "array", items: { type: "string" } }, + metadata: { type: "object" }, + }, + required: ["id", "title"], + }, + }; +} + +describe("legacyGenerateTanstackDbFile", () => { + it.effect("generates a Zod schema and TanStack DB collection per table", () => + Effect.gen(function* () { + const content = yield* legacyGenerateTanstackDbFile(todosDefinition()); + + expect(content).toContain("export const todosSchema = z.object({"); + expect(content).toContain('"id": z.number().int(),'); + expect(content).toContain('"title": z.string(),'); + expect(content).toContain("\"status\": z.enum(['open', 'closed']).nullable(),"); + expect(content).toContain('"created_at": z.string().nullable(),'); + expect(content).toContain('"tags": z.array(z.string()).nullable(),'); + expect(content).toContain('"metadata": z.record(z.unknown()).nullable(),'); + expect(content).toContain("export type Todos = z.infer"); + + expect(content).toContain( + "export const todosCollection = createCollection(supabaseCollectionOptions({", + ); + expect(content).toContain('tableName: "todos",'); + expect(content).toContain("schema: todosSchema,"); + expect(content).toContain('keys: ["id"],'); + }), + ); + + it.effect("skips tables whose name starts with an underscore", () => + Effect.gen(function* () { + const content = yield* legacyGenerateTanstackDbFile({ + ...todosDefinition(), + _internal: { properties: {} }, + }); + expect(content).not.toContain("_internal"); + }), + ); + + it.effect("falls back to a literal 'id' column when no primary-key hint is present", () => + Effect.gen(function* () { + const content = yield* legacyGenerateTanstackDbFile({ + widgets: { + properties: { id: { type: "string" }, name: { type: "string" } }, + required: ["id"], + }, + }); + expect(content).toContain('keys: ["id"],'); + }), + ); + + it.effect("composes a composite key from multiple primary-key columns", () => + Effect.gen(function* () { + const content = yield* legacyGenerateTanstackDbFile({ + memberships: { + properties: { + org_id: { type: "string", description: "Note:\nThis is a Primary Key." }, + user_id: { type: "string", description: "Note:\nThis is a Primary Key." }, + }, + required: ["org_id", "user_id"], + }, + }); + expect(content).toContain('keys: ["org_id", "user_id"],'); + }), + ); + + it.effect("keeps a primary key column name verbatim even when it isn't a valid identifier", () => + Effect.gen(function* () { + const content = yield* legacyGenerateTanstackDbFile({ + todos: { + properties: { + "not-an-id": { + type: "string", + description: "Note:\nThis is a Primary Key.", + }, + }, + required: [], + }, + }); + expect(content).toContain('keys: ["not-an-id"],'); + }), + ); + + it.effect("fails with a typed error when a table has no primary key", () => + Effect.gen(function* () { + const exit = yield* legacyGenerateTanstackDbFile({ + orphans: { properties: { name: { type: "string" } }, required: [] }, + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("has no primary key columns"); + } + }), + ); + + it.effect("fails with a typed error when no tables are found", () => + Effect.gen(function* () { + const exit = yield* legacyGenerateTanstackDbFile({ _internal: { properties: {} } }).pipe( + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("no tables found"); + } + }), + ); +}); + +describe("legacyMergeOpenApiDefinitions", () => { + it("merges multiple schema documents, later documents winning on a name collision", () => { + const merged = legacyMergeOpenApiDefinitions([ + { todos: { properties: { id: { type: "string" } } } }, + { accounts: { properties: { id: { type: "string" } } } }, + { todos: { properties: { id: { type: "string" }, extra: { type: "string" } } } }, + ]); + + expect(Object.keys(merged)).toEqual(["todos", "accounts"]); + expect(merged["todos"]?.properties).toHaveProperty("extra"); + }); +}); + +describe("legacyDecodeOpenApiDefinitions", () => { + it.effect("decodes definitions from a raw OpenAPI document", () => + Effect.gen(function* () { + const result = yield* legacyDecodeOpenApiDefinitions({ + swagger: "2.0", + definitions: { todos: { properties: {} } }, + }); + expect(result).toEqual({ todos: { properties: {} } }); + }), + ); + + it.effect("defaults to an empty map when definitions is missing", () => + Effect.gen(function* () { + const result = yield* legacyDecodeOpenApiDefinitions({ swagger: "2.0" }); + expect(result).toEqual({}); + }), + ); + + it.effect("fails with a typed decode error for a malformed document", () => + Effect.gen(function* () { + const exit = yield* legacyDecodeOpenApiDefinitions({ definitions: "not-an-object" }).pipe( + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("failed to decode database schema"); + } + }), + ); +}); diff --git a/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.handler.ts b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.handler.ts new file mode 100644 index 0000000000..12ebe90372 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.handler.ts @@ -0,0 +1,160 @@ +import { loadProjectConfig } from "@supabase/config"; +import { defaultPublishableKey } from "@supabase/stack/effect"; +import { Effect, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { ensureMutuallyExclusive } from "../../../../shared/cli/cobra-flag-groups.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { + LegacyProjectRefResolver, + PROJECT_NOT_LINKED_MESSAGE, +} from "../../../config/legacy-project-ref.service.ts"; +import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { defaultSchemas } from "../legacy-gen-schemas.ts"; +import type { LegacyGenTanstackDbFlags } from "./tanstack-db.command.ts"; +import { + LegacyGenTanstackDbLocalStackNotRunningError, + LegacyGenTanstackDbNetworkError, + LegacyGenTanstackDbUnexpectedStatusError, +} from "./tanstack-db.errors.ts"; +import { + legacyDecodeOpenApiDefinitions, + legacyGenerateTanstackDbFile, + legacyMergeOpenApiDefinitions, + type LegacyOpenApiDefinition, +} from "./tanstack-db.generators.ts"; + +const mapProjectOpenApiError = mapLegacyHttpError({ + networkError: LegacyGenTanstackDbNetworkError, + statusError: LegacyGenTanstackDbUnexpectedStatusError, + networkMessage: (cause) => `failed to get database schema: ${cause}`, + statusMessage: (_status, body) => `failed to retrieve database schema: ${body}`, +}); + +export const legacyGenTanstackDb = Effect.fn("legacy.gen.tanstack-db")(function* ( + flags: LegacyGenTanstackDbFlags, +) { + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const platformApi = yield* LegacyPlatformApiFactory; + const projectRef = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const httpClient = yield* HttpClient.HttpClient; + + yield* ensureMutuallyExclusive( + ["local", "linked", "project-id"], + [ + ...(flags.local ? ["local"] : []), + ...(flags.linked ? ["linked"] : []), + ...(Option.isSome(flags.projectId) ? ["project-id"] : []), + ], + ); + + // `flags.schema` is already CSV-parsed and validated by + // `Flag.mapTryCatch(legacyParseSchemaFlags)` in tanstack-db.command.ts. + const requestedSchemas = flags.schema; + + const fetchLocalDefinitions = Effect.gen(function* () { + const loaded = yield* loadProjectConfig(cliConfig.workdir); + if (loaded === null) { + return yield* Effect.fail(new Error("failed to load config: supabase/config.toml not found")); + } + const schemas = + requestedSchemas.length > 0 ? requestedSchemas : defaultSchemas(loaded.config.api.schemas); + const publishableKey = + loaded.config.auth.publishable_key ?? loaded.config.auth.anon_key ?? defaultPublishableKey; + const port = loaded.config.api.port; + + const fetchSchema = (schema: string) => + Effect.gen(function* () { + let request = HttpClientRequest.get(`http://127.0.0.1:${port}/rest/v1/`).pipe( + HttpClientRequest.setHeader("apikey", publishableKey), + HttpClientRequest.setHeader("Accept-Profile", schema), + ); + if (!publishableKey.startsWith("sb_")) { + request = request.pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${publishableKey}`), + ); + } + + const response = yield* httpClient.execute(request).pipe( + Effect.catch(() => + Effect.fail( + new LegacyGenTanstackDbLocalStackNotRunningError({ + message: "supabase start is not running.", + }), + ), + ), + ); + if (response.status !== 200) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* Effect.fail( + new LegacyGenTanstackDbUnexpectedStatusError({ + status: response.status, + body, + message: `failed to retrieve database schema: ${body}`, + }), + ); + } + const rawBody = yield* response.json; + return yield* legacyDecodeOpenApiDefinitions(rawBody); + }); + + const documents = yield* Effect.forEach(schemas, fetchSchema); + return legacyMergeOpenApiDefinitions(documents); + }); + + const fetchRemoteDefinitions = (ref: string) => + Effect.gen(function* () { + const api = yield* platformApi.make; + const loaded = + requestedSchemas.length > 0 ? null : yield* loadProjectConfig(cliConfig.workdir); + const schemas = + requestedSchemas.length > 0 ? requestedSchemas : defaultSchemas(loaded?.config.api.schemas); + + const documents = yield* Effect.forEach(schemas, (schema) => + api.v1 + .getDatabaseOpenapi({ ref, schema }) + .pipe( + Effect.catch(mapProjectOpenApiError), + Effect.andThen(legacyDecodeOpenApiDefinitions), + ), + ); + return legacyMergeOpenApiDefinitions(documents); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); + + yield* Effect.gen(function* () { + const definitions: Record = yield* (() => { + if (flags.local) { + return fetchLocalDefinitions; + } + if (flags.linked) { + return projectRef.resolve(Option.none()).pipe(Effect.andThen(fetchRemoteDefinitions)); + } + if (Option.isSome(flags.projectId)) { + return projectRef.resolve(flags.projectId).pipe(Effect.andThen(fetchRemoteDefinitions)); + } + return projectRef.resolve(Option.none()).pipe( + Effect.catch((cause) => { + if ( + cause instanceof LegacyProjectNotLinkedError && + cause.message === PROJECT_NOT_LINKED_MESSAGE + ) { + return Effect.fail(new Error("Must specify one of --local, --linked, or --project-id")); + } + return Effect.fail(cause); + }), + Effect.andThen(fetchRemoteDefinitions), + ); + })(); + + const content = yield* legacyGenerateTanstackDbFile(definitions); + yield* output.raw(content); + }).pipe(Effect.ensuring(telemetryState.flush)); +}); diff --git a/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.integration.test.ts b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.integration.test.ts new file mode 100644 index 0000000000..ef86342ef0 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.integration.test.ts @@ -0,0 +1,318 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "@effect/vitest"; +import type { V1GetDatabaseOpenapiOutput } from "@supabase/api/effect"; +import { Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { + buildLegacyTestRuntime, + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, + mockLegacyTelemetryStateTracked, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import type { LegacyGenTanstackDbFlags } from "./tanstack-db.command.ts"; +import { legacyGenTanstackDb } from "./tanstack-db.handler.ts"; + +function writeConfig(workdir: string, contents: string) { + const supabaseDir = join(workdir, "supabase"); + mkdirSync(supabaseDir, { recursive: true }); + writeFileSync(join(supabaseDir, "config.toml"), contents); +} + +function defaultFlags(overrides: Partial = {}): LegacyGenTanstackDbFlags { + return { + local: false, + linked: false, + projectId: Option.none(), + schema: [], + ...overrides, + }; +} + +type OpenapiOutput = typeof V1GetDatabaseOpenapiOutput.Type; + +function todosOpenapi(): OpenapiOutput { + return { + definitions: { + todos: { + properties: { + id: { type: "integer", description: "Note:\nThis is a Primary Key." }, + title: { type: "string" }, + }, + required: ["id", "title"], + }, + }, + }; +} + +function setup( + opts: { + readonly workdir?: string; + readonly projectId?: Option.Option; + readonly getDatabaseOpenapi?: (input: { + readonly ref: string; + readonly schema?: string; + }) => Effect.Effect; + readonly httpClientLayer?: Layer.Layer; + } = {}, +) { + const workdir = opts.workdir ?? mkdtempSync(join(tmpdir(), "supabase-gen-tanstack-db-")); + const out = mockOutput({ format: "text", interactive: false }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const api = mockLegacyPlatformApiService({ + v1: { + getDatabaseOpenapi: opts.getDatabaseOpenapi ?? (() => Effect.succeed(todosOpenapi())), + }, + }); + + const runtime = buildLegacyTestRuntime({ + out, + api: { layer: api.layer, httpClientLayer: opts.httpClientLayer }, + cliConfig: mockLegacyCliConfig({ + workdir, + projectId: opts.projectId ?? Option.some(LEGACY_VALID_REF), + }), + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + }); + + return { workdir, out, telemetry, linkedProjectCache, api, layer: runtime }; +} + +describe("legacy gen tanstack-db", () => { + it.live("generates a TanStack DB file for the linked project by default", () => { + const { layer, out, api, linkedProjectCache, telemetry } = setup(); + + return Effect.gen(function* () { + yield* legacyGenTanstackDb(defaultFlags()).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("export const todosSchema = z.object({"); + expect(out.stdoutText).toContain("export const todosCollection = createCollection("); + expect(api.requests).toEqual([ + { method: "getDatabaseOpenapi", input: { ref: LEGACY_VALID_REF, schema: "public" } }, + ]); + expect(linkedProjectCache.cached).toBe(true); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("generates a TanStack DB file for the explicit --linked flag", () => { + const { layer, out, api } = setup(); + + return Effect.gen(function* () { + yield* legacyGenTanstackDb(defaultFlags({ linked: true })).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("export const todosSchema = z.object({"); + expect(api.requests).toEqual([ + { method: "getDatabaseOpenapi", input: { ref: LEGACY_VALID_REF, schema: "public" } }, + ]); + }); + }); + + it.live("generates a TanStack DB file for an explicit --project-id", () => { + const { layer, out, api } = setup({ projectId: Option.none() }); + + return Effect.gen(function* () { + yield* legacyGenTanstackDb(defaultFlags({ projectId: Option.some(LEGACY_VALID_REF) })).pipe( + Effect.provide(layer), + ); + + expect(out.stdoutText).toContain("export const todosSchema = z.object({"); + expect(api.requests).toEqual([ + { method: "getDatabaseOpenapi", input: { ref: LEGACY_VALID_REF, schema: "public" } }, + ]); + }); + }); + + it.live("fetches one document per requested --schema and merges the tables", () => { + const { layer, api } = setup({ + getDatabaseOpenapi: ({ schema }) => + Effect.succeed({ + definitions: { + [`${schema}_table`]: { + properties: { + id: { type: "string", description: "Note:\nThis is a Primary Key." }, + }, + required: ["id"], + }, + }, + }), + }); + + return Effect.gen(function* () { + yield* legacyGenTanstackDb(defaultFlags({ linked: true, schema: ["public", "auth"] })).pipe( + Effect.provide(layer), + ); + + expect(api.requests).toEqual([ + { method: "getDatabaseOpenapi", input: { ref: LEGACY_VALID_REF, schema: "public" } }, + { method: "getDatabaseOpenapi", input: { ref: LEGACY_VALID_REF, schema: "auth" } }, + ]); + }); + }); + + it.live("fails when no target resolves and the project isn't linked", () => { + const { layer } = setup({ projectId: Option.none() }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTanstackDb(defaultFlags()).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "Must specify one of --local, --linked, or --project-id", + ); + } + }); + }); + + it.live("rejects combining --local and --linked", () => { + const { layer } = setup(); + + return Effect.gen(function* () { + const exit = yield* legacyGenTanstackDb(defaultFlags({ local: true, linked: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "if any flags in the group [local linked project-id] are set none of the others can be; [linked local] were all set", + ); + } + }); + }); + + it.live("fails with a typed error when a table has no primary key", () => { + const { layer } = setup({ + getDatabaseOpenapi: () => + Effect.succeed({ + definitions: { orphans: { properties: { name: { type: "string" } }, required: [] } }, + }), + }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTanstackDb(defaultFlags({ linked: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("has no primary key columns"); + } + }); + }); + + describe("--local", () => { + function setupLocal(opts: { + readonly port?: number; + readonly configExtra?: string; + readonly httpClientLayer: Layer.Layer; + }) { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-tanstack-db-local-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + `port = ${opts.port ?? 54321}`, + 'schemas = ["public"]', + opts.configExtra ?? "", + ].join("\n"), + ); + return setup({ workdir, httpClientLayer: opts.httpClientLayer }); + } + + it.live("generates a TanStack DB file from the local stack", () => { + const requests: Array<{ url: string; headers: Record }> = []; + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push({ url: request.url, headers: { ...request.headers } }); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(todosOpenapi()), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }), + ); + const { layer, out } = setupLocal({ port: 54329, httpClientLayer }); + + return Effect.gen(function* () { + yield* legacyGenTanstackDb(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("export const todosSchema = z.object({"); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("http://127.0.0.1:54329/rest/v1/"); + expect(requests[0]?.headers["accept-profile"]).toBe("public"); + expect(requests[0]?.headers["apikey"]).toBe( + "sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH", + ); + }); + }); + + it.live("reports a friendly error when the local stack isn't running", () => { + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, description: "ECONNREFUSED" }), + }), + ), + ), + ); + const { layer } = setupLocal({ httpClientLayer }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTanstackDb(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("supabase start is not running."); + } + }); + }); + + it.live("fails when supabase/config.toml is missing", () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-tanstack-db-no-config-")); + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected HttpClient.execute — no config present")), + ); + const { layer } = setup({ workdir, httpClientLayer }); + + return Effect.gen(function* () { + const exit = yield* legacyGenTanstackDb(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("supabase/config.toml not found"); + } + }); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.layers.ts b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.layers.ts new file mode 100644 index 0000000000..de2081c951 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/tanstack-db/tanstack-db.layers.ts @@ -0,0 +1,74 @@ +import { Layer } from "effect"; + +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { + LegacyIdentityStitch, + legacyIdentityStitchLayer, +} from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +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 * as HttpClient from "effect/unstable/http/HttpClient"; + +/** + * Unlike `gen types`, `--local` still needs the HTTP client directly (a plain + * `GET /rest/v1/` against the local stack's API gateway), so `httpClient` is + * exposed at the top level rather than only threaded into + * `legacyLinkedProjectCacheLayer`. + */ +export const legacyGenTanstackDbRuntimeLayer = (() => { + const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + ); + const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), + ); + + const built = Layer.mergeAll( + cliConfig, + httpClient, + platformApiFactory, + legacyProjectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliConfig)), + legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), + ), + legacyTelemetryStateLayer, + legacyIdentityStitchLayer, + commandRuntimeLayer(["gen", "tanstack-db"]), + ); + + const _serviceCoverageCheck: Layer.Layer = built; + void _serviceCoverageCheck; + + return built; +})(); + +type LegacyGenTanstackDbServices = + | LegacyPlatformApiFactory + | LegacyCliConfig + | LegacyProjectRefResolver + | LegacyLinkedProjectCache + | LegacyTelemetryState + | LegacyIdentityStitch + | CommandRuntime + | HttpClient.HttpClient; 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..e00b718c05 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -8,7 +8,7 @@ import { } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { - cobraMutuallyExclusiveErrorMessage, + ensureMutuallyExclusive, hasExplicitLongFlag, } from "../../../../shared/cli/cobra-flag-groups.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -40,8 +40,8 @@ import type { LegacyGenTypesFlags } from "./types.command.ts"; import { LegacyGenTypesNetworkError, LegacyGenTypesUnexpectedStatusError } from "./types.errors.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { defaultSchemas } from "../legacy-gen-schemas.ts"; import { - defaultSchemas, buildPostgresUrl, localDbContainerId, localDbPassword, @@ -85,16 +85,6 @@ function isProjectNotFound(cause: unknown) { const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; -function ensureMutuallyExclusive( - group: ReadonlyArray, - present: ReadonlyArray, -): Effect.Effect { - if (present.length <= 1) { - return Effect.void; - } - return Effect.fail(new Error(cobraMutuallyExclusiveErrorMessage(group, present))); -} - function forwardByteStream( stream: Stream.Stream, write: (text: string) => Effect.Effect, 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..8f29bd63e0 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -38,10 +38,6 @@ export interface LegacyGenTypesDbTarget { readonly networkMode: "host" | string; } -export function defaultSchemas(extraSchemas: ReadonlyArray = []) { - return [...new Set(["public", ...extraSchemas])]; -} - export function parseQueryTimeoutSeconds( raw: string, ): Effect.Effect { 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 44bf4d9598..33b7f46bc0 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 @@ -2,9 +2,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 { defaultSchemas } from "../legacy-gen-schemas.ts"; import { buildPostgresUrl, - defaultSchemas, legacyRootCaBundle, localDbContainerId, localDbPassword, diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index bb3e20716a..9795936785 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -1,3 +1,5 @@ +import { Effect } from "effect"; + /** * Whether `--` (or `--=`) appears in the raw argv after * the command path, matching cobra's `pflag.Changed` semantics — a flag @@ -95,3 +97,19 @@ export function cobraMutuallyExclusiveErrorMessage( const set = [...changed].sort().join(" "); return `if any flags in the group [${flagList}] are set none of the others can be; [${set}] were all set`; } + +/** + * Fails with cobra's mutually-exclusive-flag-group message when more than one + * flag in `present` (the subset of `group` that was actually set) is set. + * Shared by every command whose target-selection flags (e.g. `--local`, + * `--linked`, `--project-id`) form a cobra exclusive flag group. + */ +export function ensureMutuallyExclusive( + group: ReadonlyArray, + present: ReadonlyArray, +): Effect.Effect { + if (present.length <= 1) { + return Effect.void; + } + return Effect.fail(new Error(cobraMutuallyExclusiveErrorMessage(group, present))); +} diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts index 2a410121da..d1917d554d 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts @@ -1,8 +1,10 @@ +import { Effect, Exit } from "effect"; import { describe, expect, test } from "vitest"; import { cobraMutuallyExclusiveErrorMessage, hasExplicitLongFlag, hasExplicitValueFlag, + ensureMutuallyExclusive, } from "./cobra-flag-groups.ts"; const COMMAND_PATH = ["functions", "deploy"] as const; @@ -153,3 +155,27 @@ describe("cobraMutuallyExclusiveErrorMessage", () => { ); }); }); + +describe("ensureMutuallyExclusive", () => { + test("succeeds when zero flags in the group are set", () => { + const exit = Effect.runSyncExit(ensureMutuallyExclusive(["local", "linked"], [])); + expect(Exit.isSuccess(exit)).toBe(true); + }); + + test("succeeds when exactly one flag in the group is set", () => { + const exit = Effect.runSyncExit(ensureMutuallyExclusive(["local", "linked"], ["local"])); + expect(Exit.isSuccess(exit)).toBe(true); + }); + + test("fails with cobra's message when more than one flag is set", () => { + const exit = Effect.runSyncExit( + ensureMutuallyExclusive(["local", "linked", "project-id"], ["linked", "local"]), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "if any flags in the group [local linked project-id] are set none of the others can be; [linked local] were all set", + ); + } + }); +});