From 353d5a0251c4a75729790d53b2c6fc0b57c3f747 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 12 Nov 2026 21:57:55 -0300 Subject: [PATCH 1/3] feat(typegen): build supabase-typegen from source at the pinned supabase-swift commit supabase gen types --lang swift now builds supabase-typegen from the supabase-swift commit in the app's nearest Package.resolved, with the generator's committed dependency versions and the user's toolchain, instead of downloading the prebuilt release binary. The binary is cached per commit. Linux builds link the Swift runtime statically, and the build steps remove git's repository variables so a git hook cannot point them at the user's repository. Fixes SDK-3043 --- packages/typegen/CLAUDE.md | 21 +- packages/typegen/README.md | 49 ++- packages/typegen/src/contract.ts | 5 +- packages/typegen/src/errors.ts | 5 +- packages/typegen/src/languages/external.ts | 3 +- packages/typegen/src/languages/swift.ts | 363 ++++++++++----- packages/typegen/test/swift.test.ts | 487 ++++++++++++++------- 7 files changed, 646 insertions(+), 287 deletions(-) diff --git a/packages/typegen/CLAUDE.md b/packages/typegen/CLAUDE.md index b95eac8..783b679 100644 --- a/packages/typegen/CLAUDE.md +++ b/packages/typegen/CLAUDE.md @@ -39,13 +39,15 @@ lives in supabase-js. - `src/languages/dart.ts` -- the first out-of-process entry: `dart run supabase_typegen --output -`. No `--schema` flag: the document on stdin already holds the schemas the consumer introspected. -- `src/languages/swift.ts` -- `supabase-typegen --access-control `, a - prebuilt binary from supabase-swift's releases (SDK-2199). It downloads the - release the nearest `Package.resolved` pins into the user cache, then falls - back to PATH; `SUPABASE_TYPEGEN` overrides both. The only entry that touches - the network or the file system; `createSwift` takes `fetch` and the platform - so tests stay offline. `swift-access-control` maps to `--access-control` and - accepts only `internal` and `public`. Exit `65` is a rejected document. +- `src/languages/swift.ts` -- `supabase-typegen --access-control `, built + from source (SDK-3043) at the supabase-swift commit the nearest + `Package.resolved` pins, with the user's `git` and `swift`, and cached per + commit; then falls back to PATH; `SUPABASE_TYPEGEN` overrides both. The build + steps run through `host.spawn` with a temporary folder as `cwd`. The only + entry that touches the network or the file system; `createSwift` takes the + platform, the temporary directory and the progress `log` so tests stay + hermetic. `swift-access-control` maps to `--access-control` and accepts only + `internal` and `public`. Exit `65` is a rejected document. - `src/languages/index.ts` -- the three in-process entries and the `languages` list. `typescript` has no user options; `detect-one-to-one-relationships` (default true; the CLI's deprecated `--postgrest-v9-compat` sets it false), @@ -107,5 +109,6 @@ with `parseGeneratorMetadata`. `test/in-process.test.ts` asserts each bundled language's output equals the direct generator call on sorted input (the byte-identity acceptance criterion, without Docker). `test/dart.test.ts` and `test/swift.test.ts` cover the handoff and every error classification with the -fake host from `test/helpers.ts`; `test/node-host.test.ts` runs a fake `dart` -shell script on PATH. +fake host from `test/helpers.ts`. `test/swift.test.ts` also runs the source +build through `createNodeHost` against fake `git` and `swift` shell scripts on +PATH; `test/node-host.test.ts` runs a fake `dart` shell script on PATH. diff --git a/packages/typegen/README.md b/packages/typegen/README.md index 942f3f2..81889e5 100644 --- a/packages/typegen/README.md +++ b/packages/typegen/README.md @@ -62,7 +62,7 @@ not. | `typescript` | in-process | `generateTypescript` from `@supabase/postgrest-typegen` | consumer: `detect-one-to-one-relationships`, `postgrest-version`, `default-schema` | | `go` | in-process | `generateGo` from `@supabase/postgrest-typegen` | | | `python` | in-process | `generatePython` from `@supabase/postgrest-typegen` | | -| `swift` | out-of-process | `supabase-typegen --access-control `, fetched | `--swift-access-control internal\|public` | +| `swift` | out-of-process | `supabase-typegen --access-control `, built | `--swift-access-control internal\|public` | | `dart` | out-of-process | `dart run supabase_typegen --output -` in the project | | The three in-process entries are a transition: as each generator relocates to @@ -70,19 +70,46 @@ its SDK repository (SDK-1641), its entry here changes to an out-of-process command, or for TypeScript to an import of the new npm package, and consumers notice nothing but a dependency bump. -Swift moved first. `supabase-typegen` is a prebuilt executable from the -supabase-swift releases, not a package dependency: as a SwiftPM product its +Swift moved first. `supabase-typegen` is an executable in supabase-swift's +`tools/supabase-typegen`, not a package dependency: as a SwiftPM product its swift-format dependency would pin every app's swift-syntax, and an Xcode project without a `Package.swift` has nothing to `swift run` from. -The output targets one version's `@Table` macro, so the binary must match the -project's supabase-swift. The entry reads that version from the nearest -`Package.resolved` (in `cwd` or a parent; SwiftPM's, or Xcode's inside the -`.xcodeproj` or `.xcworkspace`), downloads that release's asset once into -`$XDG_CACHE_HOME/supabase/typegen//` (default `~/Library/Caches` on -macOS, `~/.cache` elsewhere) and runs it. Without a version pin, or on a -platform with no asset (anything but macOS and Linux x86_64), it runs -`supabase-typegen` from PATH. `SUPABASE_TYPEGEN=` overrides both. +The output targets one commit's `@Table` macro, so the generator must match the +project's supabase-swift. The entry reads the supabase-swift pin from the +nearest `Package.resolved` (in `cwd` or a parent; SwiftPM's, or Xcode's inside +the `.xcodeproj` or `.xcworkspace`) and builds the generator from source at the +pin's `revision`, so version, branch and revision pins all work: + +1. The revision must be a 40-character lowercase hex SHA; nothing else reaches + `git`. +2. A binary already in `$XDG_CACHE_HOME/supabase/typegen//` (default + `~/Library/Caches` on macOS, `~/.cache` elsewhere; an empty or relative + `XDG_CACHE_HOME` is ignored) runs with no build. +3. Otherwise it checks that `git` and `swift` (Swift 6.2 / Xcode 26 or newer) + are on PATH, fetches the single commit into a temporary folder, checks that + `HEAD` is the revision, and runs + `swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution`. + The flag builds the generator's committed `Package.resolved` exactly and + fails rather than resolve other dependency versions. Linux adds + `--static-swift-stdlib`, so the cached binary keeps working after the + toolchain that built it is removed or upgraded. Git's repository variables + (`GIT_DIR`, `GIT_INDEX_FILE` and the rest of + `git rev-parse --local-env-vars`) are removed from these steps, so running + inside a git hook cannot touch the user's repository. +4. For a version pin, `supabase-typegen --version` must print that version. +5. The binary is copied beside its cache path and renamed into place, so + concurrent runs are safe. The temporary folder is removed in every case. + +The first build prints a line to stderr and takes a few minutes, mostly +compiling swift-syntax: about 5.5 minutes on an Apple M3 with Swift 6.4, and +about the same on Linux aarch64 with Swift 6.2 in Docker on that machine. It +trusts nothing the app does not already run: the supabase-swift source at the +pinned commit, the generator's pinned dependencies and the user's toolchain. +supabase-swift 2.x has no `tools/supabase-typegen` and fails with a request to +update to 3.0.0-alpha.1 or newer. Without a supabase-swift pin, or on Windows, +the entry runs `supabase-typegen` from PATH. `SUPABASE_TYPEGEN=` overrides +both. `--swift-access-control` lost `private` and `package`, which the new generator does not accept. diff --git a/packages/typegen/src/contract.ts b/packages/typegen/src/contract.ts index 4cd8eb5..c82c625 100644 --- a/packages/typegen/src/contract.ts +++ b/packages/typegen/src/contract.ts @@ -8,7 +8,10 @@ export interface SpawnRequest { /** Executable name, resolved against `PATH` by the host (for example `dart`). */ readonly command: string; readonly args: readonly string[]; - /** Directory to run in; always the host's `cwd`, the user's project. */ + /** + * Directory to run in: the host's `cwd`, the user's project, for a + * generator, or a directory the language entry chose for a setup step. + */ readonly cwd: string; readonly env: Readonly>; /** Complete document to write to the child's stdin before closing it. */ diff --git a/packages/typegen/src/errors.ts b/packages/typegen/src/errors.ts index a1cd673..06f6ea0 100644 --- a/packages/typegen/src/errors.ts +++ b/packages/typegen/src/errors.ts @@ -61,7 +61,10 @@ export class SpawnUnavailableError extends TypegenError { } } -/** An out-of-process generator exited unsuccessfully. */ +/** + * An out-of-process generator, or a step that builds one, exited + * unsuccessfully. + */ export class ToolFailedError extends TypegenError { readonly command: readonly string[]; readonly exitCode: number | null; diff --git a/packages/typegen/src/languages/external.ts b/packages/typegen/src/languages/external.ts index 961b395..4c8a3f9 100644 --- a/packages/typegen/src/languages/external.ts +++ b/packages/typegen/src/languages/external.ts @@ -44,7 +44,8 @@ export interface ExternalTool { classify?(result: SpawnResult): ToolFailure | undefined; } -const isCommandNotFound = (error: unknown): boolean => +/** Whether `Host.spawn` rejected because the command is not on PATH. */ +export const isCommandNotFound = (error: unknown): boolean => typeof error === "object" && error !== null && "code" in error && diff --git a/packages/typegen/src/languages/swift.ts b/packages/typegen/src/languages/swift.ts index 4d556b6..7adde94 100644 --- a/packages/typegen/src/languages/swift.ts +++ b/packages/typegen/src/languages/swift.ts @@ -1,23 +1,40 @@ import { existsSync } from "node:fs"; import { chmod, + copyFile, mkdir, + mkdtemp, readdir, readFile, rename, - writeFile, + rm, } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { gunzipSync } from "node:zlib"; -import type { ChoiceOptionSpec, Host, TypegenLanguage } from "../contract.ts"; -import { ToolNotInstalledError, TypegenError } from "../errors.ts"; +import { homedir, tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import type { + ChoiceOptionSpec, + Host, + SpawnRequest, + SpawnResult, + TypegenLanguage, +} from "../contract.ts"; +import { + ToolFailedError, + ToolNotInstalledError, + TypegenError, +} from "../errors.ts"; import { resolveOptions } from "../options.ts"; -import { externalLanguage } from "./external.ts"; +import { externalLanguage, isCommandNotFound } from "./external.ts"; const SWIFT_ACCESS_CONTROL = "swift-access-control"; const BINARY = "supabase-typegen"; -const RELEASES = "https://github.com/supabase/supabase-swift/releases"; +const SOURCE = "https://github.com/supabase/supabase-swift"; const REPOSITORY = /github\.com[/:]supabase\/supabase-swift(\.git)?\/?$/i; +const PACKAGE_PATH = "tools/supabase-typegen"; +const TOOLCHAIN = "Swift 6.2 / Xcode 26"; +const FIRST_RELEASE = "3.0.0-alpha.1"; +/** The only revision form ever passed to git, so a pin cannot smuggle in an option. */ +const COMMIT = /^[0-9a-f]{40}$/; /** * The flag `supabase gen types` already exposes, narrowed to the two levels @@ -33,19 +50,15 @@ const swiftAccessControl = { help: "Access control for Swift generated types.", } satisfies ChoiceOptionSpec; -const installHint = `Set SUPABASE_TYPEGEN to a \`${BINARY}\` binary, or put one on PATH. Download it from the supabase-swift release your project depends on (${RELEASES}).`; - -/** Release asset names, keyed by `process.platform` and `process.arch`. */ -const assets: Readonly> = { - "darwin-arm64": "supabase-typegen-macos-universal.tar.gz", - "darwin-x64": "supabase-typegen-macos-universal.tar.gz", - "linux-x64": "supabase-typegen-linux-x86_64.tar.gz", -}; +const installHint = `Install git and ${TOOLCHAIN} or newer so \`${BINARY}\` can be built from the supabase-swift commit your project pins, or set SUPABASE_TYPEGEN to a \`${BINARY}\` binary, or put one on PATH.`; export interface SwiftDependencies { + /** `process.platform`. Windows skips the source build. */ readonly platform: string; - readonly arch: string; - readonly fetch: (url: string, init?: RequestInit) => Promise; + /** Where the source build makes its scratch checkout. */ + readonly temporaryDirectory: () => string; + /** Progress the user should see while the generator builds. */ + readonly log: (message: string) => void; } /** @@ -53,24 +66,28 @@ export interface SwiftDependencies { * on stdin and writes one file of `@Table` structs to stdout. * * The generated code targets the `@Table` macro of one supabase-swift - * version, so the binary must come from the same release as the project's - * dependency. The entry reads that version from the nearest - * `Package.resolved` (SwiftPM, or Xcode's copy inside the `.xcodeproj` or - * `.xcworkspace`), downloads the release's binary once into the user's cache - * and runs it. Without a version pin, or on a platform with no release asset, - * it runs `supabase-typegen` from PATH. `SUPABASE_TYPEGEN` overrides both. + * commit, so the generator must come from the same commit as the project's + * dependency. The entry reads that commit from the nearest `Package.resolved` + * (SwiftPM, or Xcode's copy inside the `.xcodeproj` or `.xcworkspace`), + * builds the generator there from source once with the user's own `git` and + * `swift`, caches the binary per commit and runs it. Without a supabase-swift + * pin, or on Windows, it runs `supabase-typegen` from PATH. + * `SUPABASE_TYPEGEN` overrides both. * - * It is a binary, not a package dependency, because its swift-format - * dependency would pin every app's swift-syntax, and because an Xcode project - * without a `Package.swift` has nothing to `swift run` from. No `--schema` is - * passed: the document already holds exactly the schemas the consumer - * introspected, and the tool generates all of them. + * A source build trusts nothing beyond what the app already runs: the + * supabase-swift source at the pinned commit, the generator's committed + * `Package.resolved`, and the user's toolchain. It is a separate executable, + * not a package dependency, because its swift-format dependency would pin + * every app's swift-syntax, and because an Xcode project without a + * `Package.swift` has nothing to `swift run` from. No `--schema` is passed: + * the document already holds exactly the schemas the consumer introspected, + * and the tool generates all of them. */ export function createSwift( dependencies: SwiftDependencies = { platform: process.platform, - arch: process.arch, - fetch: (url, init) => fetch(url, init), + temporaryDirectory: tmpdir, + log: (message) => process.stderr.write(`${message}\n`), }, ): TypegenLanguage { const options = [swiftAccessControl]; @@ -99,77 +116,246 @@ export const swift = createSwift(); async function resolveBinary( host: Host, - { platform, arch, fetch }: SwiftDependencies, + dependencies: SwiftDependencies, ): Promise { const override = host.env.SUPABASE_TYPEGEN; if (override) { return override; } - const asset = assets[`${platform}-${arch}`]; - const version = asset ? await pinnedVersion(host.cwd) : undefined; - if (!asset || !version) { + const { spawn } = host; + // Without spawn, externalLanguage reports SpawnUnavailableError for the generator itself. + if (dependencies.platform === "win32" || spawn === undefined) { return BINARY; } - const path = join(cacheDirectory(host.env, platform), version, BINARY); - if (existsSync(path)) { - return path; - } - const url = `${RELEASES}/download/v${version}/${asset}`; - const response = await fetch(url, host.signal ? { signal: host.signal } : {}); - if (response.status === 404) { - throw new ToolNotInstalledError({ - language: "swift", - tool: BINARY, - installHint, - message: `supabase-swift ${version}, which ${host.cwd} depends on, has no \`${BINARY}\` release asset (${url}). Update supabase-swift to a release that ships it, or: ${installHint}`, - }); + const pin = await pinnedState(host.cwd); + if (pin?.revision === undefined) { + return BINARY; } - if (!response.ok) { + const { revision, version } = pin; + if (!COMMIT.test(revision)) { throw new TypegenError({ language: "swift", - message: `Downloading ${url} failed with HTTP ${response.status}. ${installHint}`, + message: `The supabase-swift pin in ${host.cwd}'s Package.resolved has revision "${revision}", which is not a 40-character lowercase hex commit SHA. Resolve the package again, or: ${installHint}`, }); } - const binary = extract( - gunzipSync(new Uint8Array(await response.arrayBuffer())), + const path = join( + cacheDirectory(host.env, dependencies.platform), + revision, BINARY, ); - if (!binary) { - throw new TypegenError({ - language: "swift", - message: `${url} does not contain \`${BINARY}\`. ${installHint}`, - }); + if (!existsSync(path)) { + await build({ host, spawn, dependencies, revision, version, path }); } - // Written beside the target and renamed, so a concurrent run never sees a partial file. - await mkdir(dirname(path), { recursive: true }); - const partial = `${path}.${process.pid}.partial`; - await writeFile(partial, binary); - await chmod(partial, 0o755); - await rename(partial, path); return path; } +interface Build { + readonly host: Host; + readonly spawn: NonNullable; + readonly dependencies: SwiftDependencies; + readonly revision: string; + readonly version: string | undefined; + /** Where the finished binary goes in the cache. */ + readonly path: string; +} + +/** + * Checks out `revision` into a scratch folder, builds the generator with its + * committed dependency versions and moves the binary to `path`. The scratch + * folder is removed whatever happens, and the cache only ever receives a + * binary that built and, for a version pin, reports that version. + */ +async function build({ + host, + spawn, + dependencies, + revision, + version, + path, +}: Build): Promise { + const label = version ?? revision; + const run = async ( + command: string, + args: readonly string[], + cwd: string, + ): Promise => { + const request: SpawnRequest = { + command, + args, + cwd, + env: buildEnvironment(host.env), + stdin: "", + signal: host.signal, + }; + let result: SpawnResult; + try { + result = await spawn(request); + } catch (error) { + if (isCommandNotFound(error)) { + throw new ToolNotInstalledError({ + language: "swift", + tool: command, + installHint, + message: `Building \`${BINARY}\` ${label} from source needs \`${command}\` and ${TOOLCHAIN} or newer, and \`${command}\` was not found on PATH. ${installHint}`, + cause: error, + }); + } + throw error; + } + if (result.exitCode !== 0) { + const commandLine = [command, ...args]; + const output = [result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n"); + throw new ToolFailedError({ + language: "swift", + command: commandLine, + exitCode: result.exitCode, + stderr: result.stderr.trim(), + message: `Building \`${BINARY}\` ${label} from source failed: \`${commandLine.join(" ")}\` ${ + result.exitCode === null + ? "was terminated by a signal" + : `exited with code ${result.exitCode}` + }.${output ? `\n${output}` : ""}`, + }); + } + return result; + }; + + await run("git", ["--version"], host.cwd); + await run("swift", ["--version"], host.cwd); + dependencies.log( + `Building ${BINARY} ${label} from source (supabase-swift ${revision}). This takes a few minutes; later runs use the cached build.`, + ); + const scratch = await mkdtemp( + join(dependencies.temporaryDirectory(), `${BINARY}-`), + ); + // Named after the scratch folder, which is unique, so concurrent runs never share one. + const partial = `${path}.${process.pid}.${basename(scratch)}.partial`; + try { + await run("git", ["init"], scratch); + await run("git", ["fetch", "--depth", "1", SOURCE, revision], scratch); + await run("git", ["checkout", "--detach", "FETCH_HEAD"], scratch); + const head = (await run("git", ["rev-parse", "HEAD"], scratch)).stdout; + if (head.trim() !== revision) { + throw new TypegenError({ + language: "swift", + message: `Fetching supabase-swift ${revision} checked out ${head.trim()} instead.`, + }); + } + if (!existsSync(join(scratch, PACKAGE_PATH, "Package.swift"))) { + throw new ToolNotInstalledError({ + language: "swift", + tool: BINARY, + installHint: `Update supabase-swift to ${FIRST_RELEASE} or newer.`, + message: `supabase-swift ${label} has no ${BINARY}; update to ${FIRST_RELEASE} or newer.`, + }); + } + // --disable-automatic-resolution builds the committed Package.resolved exactly, or fails. + // On Linux the binary would otherwise load the Swift runtime from the toolchain that + // built it, and break for good once that toolchain is removed or upgraded. + await run( + "swift", + [ + "build", + "-c", + "release", + "--package-path", + PACKAGE_PATH, + "--disable-automatic-resolution", + ...(dependencies.platform === "linux" ? ["--static-swift-stdlib"] : []), + ], + scratch, + ); + const built = join(scratch, PACKAGE_PATH, ".build", "release", BINARY); + if (version !== undefined) { + const reported = (await run(built, ["--version"], scratch)).stdout.trim(); + if (reported !== version) { + throw new TypegenError({ + language: "swift", + message: `The \`${BINARY}\` built from supabase-swift ${revision} reports version ${reported}, but Package.resolved pins ${version}.`, + }); + } + } + // Copied beside the target and renamed, so a concurrent run never sees a partial file. + await mkdir(dirname(path), { recursive: true }); + await copyFile(built, partial); + await chmod(partial, 0o755); + await rename(partial, path); + } finally { + await rm(partial, { force: true }); + await rm(scratch, { recursive: true, force: true }); + } +} + +/** + * What git exports to hooks, from `git rev-parse --local-env-vars`. Inherited, + * they would point the scratch checkout, and SwiftPM's own git calls, at the + * user's repository. `undefined` removes a variable even from a host that + * merges `process.env` in, since Node and Bun skip undefined values. + */ +const GIT_REPOSITORY_VARIABLES = [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_OBJECT_DIRECTORY", + "GIT_PREFIX", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", +]; + +function buildEnvironment( + env: Readonly>, +): Record { + return { + ...env, + ...Object.fromEntries( + GIT_REPOSITORY_VARIABLES.map((name) => [name, undefined]), + ), + // A credential prompt would hang the CLI; the repository is public. + GIT_TERMINAL_PROMPT: "0", + }; +} + +/** Relative or empty values are ignored, as the XDG base directory spec asks. */ function cacheDirectory( env: Readonly>, platform: string, ): string { + const absolute = (path: string | undefined) => + path && isAbsolute(path) ? path : undefined; const base = - env.XDG_CACHE_HOME ?? - join(env.HOME ?? "", platform === "darwin" ? "Library/Caches" : ".cache"); + absolute(env.XDG_CACHE_HOME) ?? + join( + absolute(env.HOME) ?? homedir(), + platform === "darwin" ? "Library/Caches" : ".cache", + ); return join(base, "supabase", "typegen"); } +interface PinState { + readonly version?: string; + readonly revision?: string; +} + /** - * The supabase-swift version pinned by the nearest `Package.resolved`, looking - * in `cwd` and then each parent. Returns `undefined` when nothing pins it to a - * version, for example a branch or revision pin. + * The supabase-swift pin of the nearest `Package.resolved` that has one, + * looking in `cwd` and then each parent. */ -async function pinnedVersion(cwd: string): Promise { +async function pinnedState(cwd: string): Promise { for (let directory = cwd; ; directory = dirname(directory)) { for (const file of await resolvedFiles(directory)) { - const version = versionIn(await readFile(file, "utf8")); - if (version) { - return version; + const state = stateIn(await readFile(file, "utf8")); + if (state) { + return state; } } if (dirname(directory) === directory) { @@ -202,11 +388,11 @@ async function resolvedFiles(directory: string): Promise { interface Pin { readonly location?: string; readonly repositoryURL?: string; - readonly state?: { readonly version?: string }; + readonly state?: PinState; } /** Reads every `Package.resolved` format: version 1 nests the pins under `object`. */ -function versionIn(contents: string): string | undefined { +function stateIn(contents: string): PinState | undefined { let parsed: { pins?: Pin[]; object?: { pins?: Pin[] } }; try { parsed = JSON.parse(contents); @@ -216,34 +402,5 @@ function versionIn(contents: string): string | undefined { const pins = parsed.pins ?? parsed.object?.pins ?? []; return pins.find((pin) => REPOSITORY.test(pin.location ?? pin.repositoryURL ?? ""), - )?.state?.version; -} - -/** - * The regular file called `name` in an uncompressed tar archive. Enough of the - * format for the release assets: 512-byte headers, the name at offset 0, the - * size in octal at 124, the type at 156, data padded to 512 bytes. - */ -function extract(archive: Uint8Array, name: string): Uint8Array | undefined { - const text = new TextDecoder(); - for (let offset = 0; offset + 512 <= archive.length;) { - const header = archive.subarray(offset, offset + 512); - const entry = text.decode(header.subarray(0, 100)).replace(/\0.*$/s, ""); - if (entry === "") { - return undefined; - } - const size = Number.parseInt( - text.decode(header.subarray(124, 136)).replace(/\0.*$/s, "").trim() || - "0", - 8, - ); - const type = header[156]; - const start = offset + 512; - // Type "0" or NUL is a regular file; pax ("x", "g") and others are skipped. - if ((type === 0x30 || type === 0) && entry.replace(/^\.\//, "") === name) { - return archive.slice(start, start + size); - } - offset = start + Math.ceil(size / 512) * 512; - } - return undefined; + )?.state; } diff --git a/packages/typegen/test/swift.test.ts b/packages/typegen/test/swift.test.ts index 09ba2ac..f0a1eac 100644 --- a/packages/typegen/test/swift.test.ts +++ b/packages/typegen/test/swift.test.ts @@ -1,9 +1,12 @@ -import { beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { execFileSync } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { + existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, + realpathSync, + rmSync, statSync, writeFileSync, } from "node:fs"; @@ -14,6 +17,7 @@ import { sortGeneratorMetadata, } from "@supabase/postgrest-typegen"; import { + createNodeHost, InvalidOptionError, MetadataRejectedError, swift, @@ -76,9 +80,7 @@ describe("swift", () => { expect(error).toBeInstanceOf(ToolNotInstalledError); const notInstalled = error as ToolNotInstalledError; expect(notInstalled.tool).toBe("supabase-typegen"); - expect(notInstalled.installHint).toContain( - "https://github.com/supabase/supabase-swift/releases", - ); + expect(notInstalled.installHint).toContain("SUPABASE_TYPEGEN"); }); test("reports exit 65 as a rejected document", async () => { @@ -109,105 +111,207 @@ describe("swift", () => { }); }); -describe("swift binary resolution", () => { - const binary = "#!/bin/sh\necho generated\n"; - let tarball: Uint8Array; +const REVISION = "0123456789abcdef0123456789abcdef01234567"; + +/** + * Stand-ins for `git` and `swift`, put alone on PATH. Each appends its command + * line to `$FAKE_LOG`. `git` fakes a fetch of the revision it is given, and + * checking it out creates `tools/supabase-typegen` unless `FAKE_NO_TYPEGEN` is + * set. `swift build` writes a `supabase-typegen` that prints + * `$FAKE_TYPEGEN_VERSION` for `--version` and `generated` otherwise. The + * `FAKE_*_FAIL` variables make a step fail the way the real tool does. Both + * fail if a git repository variable reaches them. They + * call coreutils by absolute path because PATH holds only the fakes. + */ +const FAKE_GIT = `#!/bin/sh +echo "git $*" >> "$FAKE_LOG" +if [ -n "$GIT_DIR$GIT_WORK_TREE$GIT_INDEX_FILE$GIT_OBJECT_DIRECTORY" ]; then + echo "a repository variable leaked in" >&2 + exit 3 +fi +case "$1" in + --version) echo "git version 2.50.0" ;; + init) /bin/mkdir .git ;; + fetch) + if [ -n "$FAKE_FETCH_FAIL" ]; then + echo "fatal: remote error: upload-pack: not our ref $5" >&2 + exit 128 + fi + for argument; do revision=$argument; done + echo "$revision" > .git/FAKE_FETCH_HEAD ;; + checkout) + /bin/cp .git/FAKE_FETCH_HEAD .git/FAKE_HEAD + if [ -z "$FAKE_NO_TYPEGEN" ]; then + /bin/mkdir -p tools/supabase-typegen + echo "// swift-tools-version:6.2" > tools/supabase-typegen/Package.swift + fi ;; + rev-parse) if [ -n "$FAKE_HEAD" ]; then echo "$FAKE_HEAD"; else /bin/cat .git/FAKE_HEAD; fi ;; + *) echo "unexpected git $*" >&2; exit 2 ;; +esac +`; + +const FAKE_SWIFT = `#!/bin/sh +echo "swift $*" >> "$FAKE_LOG" +if [ -n "$GIT_DIR$GIT_WORK_TREE$GIT_INDEX_FILE$GIT_OBJECT_DIRECTORY" ]; then + echo "a repository variable leaked in" >&2 + exit 3 +fi +case "$1" in + --version) echo "Swift version 6.2" ;; + build) + if [ -n "$FAKE_BUILD_FAIL" ]; then + echo "Sources/SupabaseTypegen/Typegen.swift:1:1: error: cannot find 'x' in scope" + echo "error: fatalError" >&2 + exit 1 + fi + /bin/mkdir -p tools/supabase-typegen/.build/release + binary=tools/supabase-typegen/.build/release/supabase-typegen + printf '#!/bin/sh\\necho "typegen $*" >> "$FAKE_LOG"\\nif [ "$1" = --version ]; then echo %s; exit 0; fi\\n/bin/cat > /dev/null\\necho generated\\n' "\${FAKE_TYPEGEN_VERSION:-3.0.0-alpha.1}" > "$binary" + /bin/chmod 755 "$binary" ;; + *) echo "unexpected swift $*" >&2; exit 2 ;; +esac +`; + +describe("swift source build", () => { let root: string; + let bin: string; + let scratch: string; + let logFile: string; + let messages: string[]; - beforeAll(() => { - const source = mkdtempSync(join(tmpdir(), "typegen-asset-")); - writeFileSync(join(source, "supabase-typegen"), binary, { mode: 0o755 }); - // The system tar, as the supabase-swift release job uses: bsdtar on macOS adds pax headers. - execFileSync( - "tar", - ["-czf", "asset.tar.gz", "-C", source, "supabase-typegen"], - { - cwd: source, - }, - ); - tarball = new Uint8Array(readFileSync(join(source, "asset.tar.gz"))); + beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "typegen-swift-"))); + bin = join(root, "bin"); + scratch = join(root, "tmp"); + logFile = join(root, "commands.log"); + messages = []; + mkdirSync(bin); + mkdirSync(scratch); + writeFileSync(logFile, ""); + writeFileSync(join(bin, "git"), FAKE_GIT, { mode: 0o755 }); + writeFileSync(join(bin, "swift"), FAKE_SWIFT, { mode: 0o755 }); }); - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "typegen-swift-")); + afterEach(() => { + rmSync(root, { recursive: true, force: true }); }); - const resolvedV2 = (version: string) => + const pin = (state: Record) => JSON.stringify({ pins: [ { identity: "swift-crypto", location: "https://github.com/apple/swift-crypto.git", - state: { version: "3.0.0" }, + state: { version: "3.0.0", revision: "f".repeat(40) }, }, { identity: "supabase-swift", location: "https://github.com/supabase/supabase-swift.git", - state: { version, revision: "abc" }, + state, }, ], version: 2, }); + const versionPin = (version = "3.0.0-alpha.1") => + pin({ version, revision: REVISION }); + const project = (files: Record) => { const cwd = join(root, "app"); + mkdirSync(cwd, { recursive: true }); for (const [path, contents] of Object.entries(files)) { mkdirSync(dirname(join(cwd, path)), { recursive: true }); writeFileSync(join(cwd, path), contents); } - mkdirSync(cwd, { recursive: true }); return cwd; }; - const setup = ( + const make = (platform = "darwin") => + createSwift({ + platform, + temporaryDirectory: () => scratch, + log: (message) => messages.push(message), + }); + + const run = ( cwd: string, - respond: (url: string) => Response = () => - new Response(tarball, { status: 200 }), env: Record = {}, - ) => { - const urls: string[] = []; - const language = createSwift({ - platform: "darwin", - arch: "arm64", - fetch: async (url) => { - urls.push(String(url)); - return respond(String(url)); - }, - }); - const host = createFakeHost(() => success("code\n"), { - cwd, - env: { HOME: join(root, "home"), ...env }, - }); - return { language, host, urls }; - }; + platform = "darwin", + ) => + make(platform).generate( + unsortedMetadata, + {}, + createNodeHost({ + cwd, + env: { + PATH: bin, + HOME: join(root, "home"), + FAKE_LOG: logFile, + ...env, + }, + }), + ); + + const ran = (prefix: string) => + commands().some((line) => line.startsWith(prefix)); + const commands = () => readFileSync(logFile, "utf8").split("\n").slice(0, -1); + const cached = (cache = "home/Library/Caches") => + join(root, cache, "supabase/typegen", REVISION, "supabase-typegen"); + const leftovers = () => [ + ...readdirSync(scratch), + ...(existsSync(dirname(cached())) ? readdirSync(dirname(cached())) : []), + ]; - test("downloads the release the project's Package.resolved pins and runs it", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); + test("builds the pinned commit from source, caches the binary and runs it", async () => { + const cwd = project({ "Package.resolved": versionPin() }); - expect(await language.generate(unsortedMetadata, {}, host)).toBe("code\n"); + expect(await run(cwd)).toBe("generated\n"); - expect(urls).toEqual([ - "https://github.com/supabase/supabase-swift/releases/download/v2.56.0/supabase-typegen-macos-universal.tar.gz", + expect(commands()).toEqual([ + "git --version", + "swift --version", + "git init", + `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, + "git checkout --detach FETCH_HEAD", + "git rev-parse HEAD", + "swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution", + "typegen --version", + "typegen --access-control internal", ]); - const command = host.requests[0]!.command; - expect(command).toBe( - join( - root, - "home/Library/Caches/supabase/typegen/2.56.0/supabase-typegen", - ), + expect(statSync(cached()).mode & 0o777).toBe(0o755); + expect(readdirSync(dirname(cached()))).toEqual(["supabase-typegen"]); + expect(readdirSync(scratch)).toEqual([]); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain( + "Building supabase-typegen 3.0.0-alpha.1 from source", ); - expect(readFileSync(command, "utf8")).toBe(binary); - expect(statSync(command).mode & 0o777).toBe(0o755); + expect(messages[0]).toContain("a few minutes"); }); - test("reuses the cached binary", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); - await language.generate(unsortedMetadata, {}, host); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toHaveLength(1); - expect(host.requests).toHaveLength(2); + test("runs the cached binary without git or swift", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + await run(cwd); + writeFileSync(logFile, ""); + messages = []; + + expect(await run(cwd)).toBe("generated\n"); + + expect(commands()).toEqual(["typegen --access-control internal"]); + expect(messages).toEqual([]); + }); + + test("builds a branch pin at its revision without a version check", async () => { + const cwd = project({ + "Package.resolved": pin({ branch: "main", revision: REVISION }), + }); + + expect(await run(cwd)).toBe("generated\n"); + + expect(commands()).toContain( + `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, + ); + expect(commands()).not.toContain("typegen --version"); + expect(existsSync(cached())).toBe(true); }); test("reads an Xcode project's Package.resolved in the version 1 format", async () => { @@ -219,135 +323,196 @@ describe("swift binary resolution", () => { { package: "Supabase", repositoryURL: "https://github.com/supabase/supabase-swift", - state: { version: "2.40.0" }, + state: { version: "3.0.0-alpha.1", revision: REVISION }, }, ], }, version: 1, }), }); - const { language, host, urls } = setup(cwd); - await language.generate(unsortedMetadata, {}, host); - expect(urls[0]).toContain("/download/v2.40.0/"); + await run(cwd); + expect(existsSync(cached())).toBe(true); }); test("finds Package.resolved in a parent directory", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); + const cwd = project({ "Package.resolved": versionPin() }); mkdirSync(join(cwd, "Sources/App"), { recursive: true }); - const { language, host, urls } = setup(join(cwd, "Sources/App")); - await language.generate(unsortedMetadata, {}, host); - expect(urls[0]).toContain("/download/v2.56.0/"); + await run(join(cwd, "Sources/App")); + expect(existsSync(cached())).toBe(true); }); - test("picks the Linux asset on Linux x86_64", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const urls: string[] = []; - const language = createSwift({ - platform: "linux", - arch: "x64", - fetch: async (url) => { - urls.push(String(url)); - return new Response(tarball); - }, - }); - const host = createFakeHost(() => success(""), { - cwd, - env: { HOME: join(root, "home"), XDG_CACHE_HOME: join(root, "xdg") }, - }); - await language.generate(unsortedMetadata, {}, host); - expect(urls[0]).toEndWith("/supabase-typegen-linux-x86_64.tar.gz"); - expect(host.requests[0]!.command).toBe( - join(root, "xdg/supabase/typegen/2.56.0/supabase-typegen"), + test("links the Swift runtime statically on Linux", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + await run(cwd, {}, "linux"); + expect(commands()).toContain( + "swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution --static-swift-stdlib", ); }); - test("falls back to PATH without a version pin", async () => { - const branchPin = JSON.stringify({ - pins: [ - { - identity: "supabase-swift", - location: "https://github.com/supabase/supabase-swift", - state: { branch: "main", revision: "abc" }, - }, - ], - version: 3, - }); - for (const cwd of [ - project({}), - project({ "Package.resolved": branchPin }), - ]) { - const { language, host, urls } = setup(cwd); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toEqual([]); - expect(host.requests.at(-1)!.command).toBe("supabase-typegen"); + test("uses XDG_CACHE_HOME and ~/.cache outside macOS", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + await run(cwd, {}, "linux"); + expect(existsSync(cached("home/.cache"))).toBe(true); + await run(cwd, { XDG_CACHE_HOME: join(root, "xdg") }, "linux"); + expect(existsSync(cached("xdg"))).toBe(true); + }); + + test("ignores an empty or relative XDG_CACHE_HOME", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + for (const XDG_CACHE_HOME of ["", "relative/cache"]) { + await run(cwd, { XDG_CACHE_HOME }, "linux"); + expect(existsSync(cached("home/.cache"))).toBe(true); } }); - test("falls back to PATH on a platform without a release asset", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const language = createSwift({ - platform: "win32", - arch: "x64", - fetch: async () => fail("must not download"), - }); + test("keeps the user's git repository variables away from the build", async () => { + // Git exports these to hooks, for example when gen types runs in pre-commit. + const cwd = project({ "Package.resolved": versionPin() }); + const repository = join(root, "user-repo/.git"); + expect( + await run(cwd, { + GIT_DIR: repository, + GIT_WORK_TREE: dirname(repository), + GIT_INDEX_FILE: join(repository, "index"), + GIT_OBJECT_DIRECTORY: join(repository, "objects"), + }), + ).toBe("generated\n"); + }); + + test("two concurrent first runs both succeed", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + expect(await Promise.all([run(cwd), run(cwd)])).toEqual([ + "generated\n", + "generated\n", + ]); + expect(readdirSync(dirname(cached()))).toEqual(["supabase-typegen"]); + expect(readdirSync(scratch)).toEqual([]); + }); + + test("falls back to PATH without a supabase-swift pin", async () => { + const cwd = project({}); + writeFileSync( + join(bin, "supabase-typegen"), + '#!/bin/sh\necho "typegen $*" >> "$FAKE_LOG"\necho from-path\n', + { mode: 0o755 }, + ); + expect(await run(cwd)).toBe("from-path\n"); + expect(commands()).toEqual(["typegen --access-control internal"]); + }); + + test("falls back to PATH on Windows", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const language = make("win32"); const host = createFakeHost(() => success(""), { cwd }); await language.generate(unsortedMetadata, {}, host); - expect(host.requests[0]!.command).toBe("supabase-typegen"); + expect(host.requests.map((request) => request.command)).toEqual([ + "supabase-typegen", + ]); }); - test("SUPABASE_TYPEGEN overrides the download", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd, undefined, { - SUPABASE_TYPEGEN: "/opt/dev/supabase-typegen", + test("SUPABASE_TYPEGEN overrides the source build", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const host = createFakeHost(() => success(""), { + cwd, + env: { SUPABASE_TYPEGEN: "/opt/dev/supabase-typegen" }, }); - await language.generate(unsortedMetadata, {}, host); - expect(urls).toEqual([]); - expect(host.requests[0]!.command).toBe("/opt/dev/supabase-typegen"); + await make().generate(unsortedMetadata, {}, host); + expect(host.requests.map((request) => request.command)).toEqual([ + "/opt/dev/supabase-typegen", + ]); }); - test("reports a release without the binary as not installed", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.10.0") }); - const { language, host } = setup( - cwd, - () => new Response("Not Found", { status: 404 }), - ); - const error = await rejection( - language.generate(unsortedMetadata, {}, host), - ); + test("refuses a revision that is not a full commit SHA without running git", async () => { + for (const revision of ["abc", "--upload-pack=touch", "A".repeat(40)]) { + const cwd = project({ + "Package.resolved": pin({ version: "3.0.0-alpha.1", revision }), + }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain("40-character"); + expect(commands()).toEqual([]); + } + }); + + for (const tool of ["git", "swift"]) { + test(`names a missing ${tool} and the minimum toolchain before fetching`, async () => { + rmSync(join(bin, tool)); + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(ToolNotInstalledError); + expect((error as ToolNotInstalledError).tool).toBe(tool); + expect((error as ToolNotInstalledError).message).toContain( + "Swift 6.2 / Xcode 26", + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); + } + + test("reports a supabase-swift without supabase-typegen as not installed", async () => { + const cwd = project({ "Package.resolved": versionPin("2.56.0") }); + const error = await rejection(run(cwd, { FAKE_NO_TYPEGEN: "1" })); expect(error).toBeInstanceOf(ToolNotInstalledError); - expect((error as ToolNotInstalledError).message).toContain("2.10.0"); - expect(host.requests).toHaveLength(0); + expect((error as ToolNotInstalledError).message).toContain( + "supabase-swift 2.56.0 has no supabase-typegen; update to 3.0.0-alpha.1 or newer", + ); + expect(ran("swift build")).toBe(false); + expect(leftovers()).toEqual([]); }); - test("reports any other download failure", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host } = setup( - cwd, - () => new Response("", { status: 503 }), + test("reports a failed fetch with git's error", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd, { FAKE_FETCH_FAIL: "1" })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + "upload-pack: not our ref", ); + expect(leftovers()).toEqual([]); + }); + + test("refuses a checkout whose HEAD is not the pinned revision", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd, { FAKE_HEAD: "e".repeat(40) })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain(REVISION); + expect(ran("swift build")).toBe(false); + expect(leftovers()).toEqual([]); + }); + + test("forwards the swift build errors", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection(run(cwd, { FAKE_BUILD_FAIL: "1" })); + expect(error).toBeInstanceOf(ToolFailedError); + const { message, exitCode, stderr } = error as ToolFailedError; + expect(exitCode).toBe(1); + expect(stderr).toBe("error: fatalError"); + expect(message).toContain("swift build"); + expect(message).toContain("error: cannot find 'x' in scope"); + expect(message).toContain("error: fatalError"); + expect(leftovers()).toEqual([]); + }); + + test("refuses a binary whose --version differs from the pin, and does not cache it", async () => { + const cwd = project({ "Package.resolved": versionPin() }); const error = await rejection( - language.generate(unsortedMetadata, {}, host), + run(cwd, { FAKE_TYPEGEN_VERSION: "3.0.0-alpha.2" }), ); expect(error).toBeInstanceOf(TypegenError); - expect((error as TypegenError).message).toContain("503"); + expect((error as TypegenError).message).toContain("3.0.0-alpha.2"); + expect(commands()).not.toContain("typegen --access-control internal"); + expect(leftovers()).toEqual([]); }); - test("validates options before downloading", async () => { - const cwd = project({ "Package.resolved": resolvedV2("2.56.0") }); - const { language, host, urls } = setup(cwd); - expect( - await rejection( - language.generate( - unsortedMetadata, - { "swift-access-control": "open" }, - host, - ), + test("validates options before building", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection( + make().generate( + unsortedMetadata, + { "swift-access-control": "open" }, + createNodeHost({ cwd, env: { PATH: bin, FAKE_LOG: logFile } }), ), - ).toBeInstanceOf(InvalidOptionError); - expect(urls).toEqual([]); + ); + expect(error).toBeInstanceOf(InvalidOptionError); + expect(commands()).toEqual([]); }); }); - -const fail = (message: string): never => { - throw new Error(message); -}; From af40e09516af2415d9a9c71732558ce53a8d4d51 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 12 Nov 2026 22:38:58 -0300 Subject: [PATCH 2/3] fix(typegen): build only commits that are upstream's GitHub serves every commit of the fork network through the upstream URL, pull request heads included, so fetching the pinned SHA alone let a Package.resolved get a fork's code built and run. A version pin's tag must now point at the revision, and any other pin's revision must be on its upstream branch, checked in a commits-only clone before the fetch. The Package.resolved lookup also skips files the user does not own and non-regular files, treats malformed JSON as no pin, and rejects a version or branch that is not a plain value before git or the terminal sees it. Refs SDK-3043 --- packages/typegen/CLAUDE.md | 4 +- packages/typegen/README.md | 17 +- packages/typegen/src/languages/swift.ts | 221 +++++++++++++++++++----- packages/typegen/test/swift.test.ts | 123 ++++++++++++- 4 files changed, 310 insertions(+), 55 deletions(-) diff --git a/packages/typegen/CLAUDE.md b/packages/typegen/CLAUDE.md index 783b679..5584c27 100644 --- a/packages/typegen/CLAUDE.md +++ b/packages/typegen/CLAUDE.md @@ -42,7 +42,9 @@ lives in supabase-js. - `src/languages/swift.ts` -- `supabase-typegen --access-control `, built from source (SDK-3043) at the supabase-swift commit the nearest `Package.resolved` pins, with the user's `git` and `swift`, and cached per - commit; then falls back to PATH; `SUPABASE_TYPEGEN` overrides both. The build + commit; then falls back to PATH; `SUPABASE_TYPEGEN` overrides both. Before + fetching it proves the commit is upstream's (tag for a version pin, ancestry + of the branch otherwise), since GitHub serves fork commits by SHA. The build steps run through `host.spawn` with a temporary folder as `cwd`. The only entry that touches the network or the file system; `createSwift` takes the platform, the temporary directory and the progress `log` so tests stay diff --git a/packages/typegen/README.md b/packages/typegen/README.md index 81889e5..7b68a46 100644 --- a/packages/typegen/README.md +++ b/packages/typegen/README.md @@ -81,14 +81,23 @@ nearest `Package.resolved` (in `cwd` or a parent; SwiftPM's, or Xcode's inside the `.xcodeproj` or `.xcworkspace`) and builds the generator from source at the pin's `revision`, so version, branch and revision pins all work: -1. The revision must be a 40-character lowercase hex SHA; nothing else reaches - `git`. +1. The revision must be a 40-character lowercase hex SHA, a version must be a + semantic version and a branch a plain branch name; nothing else reaches + `git`. Only regular files the user owns count as `Package.resolved`, so + another user's file in a shared parent such as `/tmp` cannot pick the commit, + and a malformed one counts as no pin. 2. A binary already in `$XDG_CACHE_HOME/supabase/typegen//` (default `~/Library/Caches` on macOS, `~/.cache` elsewhere; an empty or relative `XDG_CACHE_HOME` is ignored) runs with no build. 3. Otherwise it checks that `git` and `swift` (Swift 6.2 / Xcode 26 or newer) - are on PATH, fetches the single commit into a temporary folder, checks that - `HEAD` is the revision, and runs + are on PATH and that the commit is upstream's. GitHub serves every commit of + the fork network through the upstream URL, pull request heads included, so a + version pin's tag (`` or `v`, per `git ls-remote`) must + point at the revision, and a branch pin's revision must be an ancestor of + that upstream branch (a bare revision pin: the default branch), checked in a + commits-only clone (`--filter=tree:0`, about 1.5 MB and a second). It then + fetches the single commit into a temporary folder, checks that `HEAD` is the + revision, and runs `swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution`. The flag builds the generator's committed `Package.resolved` exactly and fails rather than resolve other dependency versions. Linux adds diff --git a/packages/typegen/src/languages/swift.ts b/packages/typegen/src/languages/swift.ts index 7adde94..dcef7c2 100644 --- a/packages/typegen/src/languages/swift.ts +++ b/packages/typegen/src/languages/swift.ts @@ -8,6 +8,7 @@ import { readFile, rename, rm, + stat, } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join } from "node:path"; @@ -35,6 +36,10 @@ const TOOLCHAIN = "Swift 6.2 / Xcode 26"; const FIRST_RELEASE = "3.0.0-alpha.1"; /** The only revision form ever passed to git, so a pin cannot smuggle in an option. */ const COMMIT = /^[0-9a-f]{40}$/; +/** Semantic versions only: a pinned version reaches git as a tag name and the terminal as text. */ +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +/** Branch names git accepts, minus anything that could read as an option or a refspec. */ +const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]+$/; /** * The flag `supabase gen types` already exposes, narrowed to the two levels @@ -59,6 +64,12 @@ export interface SwiftDependencies { readonly temporaryDirectory: () => string; /** Progress the user should see while the generator builds. */ readonly log: (message: string) => void; + /** + * `process.getuid()`. A `Package.resolved` owned by anyone else is ignored, + * so another user's file in a shared parent such as `/tmp` cannot pick the + * commit that gets built. `undefined` skips the check. + */ + readonly userId: () => number | undefined; } /** @@ -76,7 +87,10 @@ export interface SwiftDependencies { * * A source build trusts nothing beyond what the app already runs: the * supabase-swift source at the pinned commit, the generator's committed - * `Package.resolved`, and the user's toolchain. It is a separate executable, + * `Package.resolved`, and the user's toolchain. GitHub serves every commit of + * the fork network, pull request heads included, through the upstream URL, so + * the commit must first be shown to be upstream's: a version pin's tag must + * point at it, and any other pin must be on its upstream branch. It is a separate executable, * not a package dependency, because its swift-format dependency would pin * every app's swift-syntax, and because an Xcode project without a * `Package.swift` has nothing to `swift run` from. No `--schema` is passed: @@ -88,6 +102,7 @@ export function createSwift( platform: process.platform, temporaryDirectory: tmpdir, log: (message) => process.stderr.write(`${message}\n`), + userId: () => process.getuid?.(), }, ): TypegenLanguage { const options = [swiftAccessControl]; @@ -127,16 +142,28 @@ async function resolveBinary( if (dependencies.platform === "win32" || spawn === undefined) { return BINARY; } - const pin = await pinnedState(host.cwd); + const pin = await pinnedState(host.cwd, dependencies.userId()); if (pin?.revision === undefined) { return BINARY; } - const { revision, version } = pin; - if (!COMMIT.test(revision)) { - throw new TypegenError({ + const { revision, version, branch } = pin; + const invalid = (field: string, value: string, expected: string) => + new TypegenError({ language: "swift", - message: `The supabase-swift pin in ${host.cwd}'s Package.resolved has revision "${revision}", which is not a 40-character lowercase hex commit SHA. Resolve the package again, or: ${installHint}`, + message: `The supabase-swift pin in ${host.cwd}'s Package.resolved has ${field} ${JSON.stringify(value)}, which is not ${expected}. Resolve the package again, or: ${installHint}`, }); + if (!COMMIT.test(revision)) { + throw invalid( + "revision", + revision, + "a 40-character lowercase hex commit SHA", + ); + } + if (version !== undefined && !VERSION.test(version)) { + throw invalid("version", version, "a semantic version"); + } + if (version === undefined && branch !== undefined && !BRANCH.test(branch)) { + throw invalid("branch", branch, "a plain branch name"); } const path = join( cacheDirectory(host.env, dependencies.platform), @@ -144,7 +171,13 @@ async function resolveBinary( BINARY, ); if (!existsSync(path)) { - await build({ host, spawn, dependencies, revision, version, path }); + await build({ + host, + spawn, + dependencies, + pin: { revision, version, branch }, + path, + }); } return path; } @@ -153,28 +186,29 @@ interface Build { readonly host: Host; readonly spawn: NonNullable; readonly dependencies: SwiftDependencies; - readonly revision: string; - readonly version: string | undefined; + /** A validated pin: the revision is a SHA, the version a semantic version, the branch plain. */ + readonly pin: { readonly revision: string } & PinState; /** Where the finished binary goes in the cache. */ readonly path: string; } /** - * Checks out `revision` into a scratch folder, builds the generator with its - * committed dependency versions and moves the binary to `path`. The scratch - * folder is removed whatever happens, and the cache only ever receives a - * binary that built and, for a version pin, reports that version. + * Checks that `pin.revision` is upstream's, checks it out into a scratch + * folder, builds the generator with its committed dependency versions and + * moves the binary to `path`. The scratch folder is removed whatever happens, + * and the cache only ever receives a binary that built and, for a version pin, + * reports that version. */ async function build({ host, spawn, dependencies, - revision, - version, + pin, path, }: Build): Promise { + const { revision, version, branch } = pin; const label = version ?? revision; - const run = async ( + const exec = async ( command: string, args: readonly string[], cwd: string, @@ -187,9 +221,8 @@ async function build({ stdin: "", signal: host.signal, }; - let result: SpawnResult; try { - result = await spawn(request); + return await spawn(request); } catch (error) { if (isCommandNotFound(error)) { throw new ToolNotInstalledError({ @@ -202,6 +235,13 @@ async function build({ } throw error; } + }; + const run = async ( + command: string, + args: readonly string[], + cwd: string, + ): Promise => { + const result = await exec(command, args, cwd); if (result.exitCode !== 0) { const commandLine = [command, ...args]; const output = [result.stdout.trim(), result.stderr.trim()] @@ -232,18 +272,73 @@ async function build({ ); // Named after the scratch folder, which is unique, so concurrent runs never share one. const partial = `${path}.${process.pid}.${basename(scratch)}.partial`; + const checkout = join(scratch, "src"); try { - await run("git", ["init"], scratch); - await run("git", ["fetch", "--depth", "1", SOURCE, revision], scratch); - await run("git", ["checkout", "--detach", "FETCH_HEAD"], scratch); - const head = (await run("git", ["rev-parse", "HEAD"], scratch)).stdout; + if (version !== undefined) { + // A tag can only come from upstream's maintainers; annotated tags list the commit as `^{}`. + const tags = [version, `v${version}`].map((name) => `refs/tags/${name}`); + const listed = await run( + "git", + ["ls-remote", SOURCE, ...tags.flatMap((tag) => [tag, `${tag}^{}`])], + scratch, + ); + const commits = new Map( + listed.stdout + .split("\n") + .map((line) => line.split("\t")) + .map(([sha, ref]) => [ref?.trim(), sha?.trim()]), + ); + if ( + !tags.some( + (tag) => (commits.get(`${tag}^{}`) ?? commits.get(tag)) === revision, + ) + ) { + throw new TypegenError({ + language: "swift", + message: `supabase-swift's ${version} tag does not point at ${revision}, the revision Package.resolved pins. Resolve the package again; a revision from a fork or a pull request is not built.`, + }); + } + } else { + // Only the commits of one branch, about 1.5 MB, to show the revision is on it. + const branchArgs = branch === undefined ? [] : ["--branch", branch]; + await run( + "git", + [ + "clone", + "--bare", + "--filter=tree:0", + "--no-tags", + "--single-branch", + ...branchArgs, + SOURCE, + "history.git", + ], + scratch, + ); + const ancestry = await exec( + "git", + ["merge-base", "--is-ancestor", revision, "HEAD"], + join(scratch, "history.git"), + ); + if (ancestry.exitCode !== 0) { + throw new TypegenError({ + language: "swift", + message: `${revision} is not on supabase-swift's ${branch ?? "default"} branch. Resolve the package again; a revision from a fork or a pull request is not built.`, + }); + } + } + await mkdir(checkout); + await run("git", ["init"], checkout); + await run("git", ["fetch", "--depth", "1", SOURCE, revision], checkout); + await run("git", ["checkout", "--detach", "FETCH_HEAD"], checkout); + const head = (await run("git", ["rev-parse", "HEAD"], checkout)).stdout; if (head.trim() !== revision) { throw new TypegenError({ language: "swift", message: `Fetching supabase-swift ${revision} checked out ${head.trim()} instead.`, }); } - if (!existsSync(join(scratch, PACKAGE_PATH, "Package.swift"))) { + if (!existsSync(join(checkout, PACKAGE_PATH, "Package.swift"))) { throw new ToolNotInstalledError({ language: "swift", tool: BINARY, @@ -265,11 +360,13 @@ async function build({ "--disable-automatic-resolution", ...(dependencies.platform === "linux" ? ["--static-swift-stdlib"] : []), ], - scratch, + checkout, ); - const built = join(scratch, PACKAGE_PATH, ".build", "release", BINARY); + const built = join(checkout, PACKAGE_PATH, ".build", "release", BINARY); if (version !== undefined) { - const reported = (await run(built, ["--version"], scratch)).stdout.trim(); + const reported = ( + await run(built, ["--version"], checkout) + ).stdout.trim(); if (reported !== version) { throw new TypegenError({ language: "swift", @@ -343,16 +440,21 @@ function cacheDirectory( interface PinState { readonly version?: string; + readonly branch?: string; readonly revision?: string; } /** * The supabase-swift pin of the nearest `Package.resolved` that has one, - * looking in `cwd` and then each parent. + * looking in `cwd` and then each parent. Only regular files that `userId` + * owns count. */ -async function pinnedState(cwd: string): Promise { +async function pinnedState( + cwd: string, + userId: number | undefined, +): Promise { for (let directory = cwd; ; directory = dirname(directory)) { - for (const file of await resolvedFiles(directory)) { + for (const file of await resolvedFiles(directory, userId)) { const state = stateIn(await readFile(file, "utf8")); if (state) { return state; @@ -364,9 +466,12 @@ async function pinnedState(cwd: string): Promise { } } -async function resolvedFiles(directory: string): Promise { +async function resolvedFiles( + directory: string, + userId: number | undefined, +): Promise { const entries = await readdir(directory).catch(() => []); - return [ + const candidates = [ join(directory, "Package.resolved"), ...entries .filter((entry) => entry.endsWith(".xcworkspace")) @@ -382,25 +487,55 @@ async function resolvedFiles(directory: string): Promise { "project.xcworkspace/xcshareddata/swiftpm/Package.resolved", ), ), - ].filter((file) => existsSync(file)); + ]; + const usable = await Promise.all( + candidates.map(async (file) => { + const status = await stat(file).catch(() => undefined); + // A FIFO would block the read forever; a file of another user is not the project's. + return ( + status?.isFile() === true && + (userId === undefined || status.uid === userId) + ); + }), + ); + return candidates.filter((_file, index) => usable[index]); } -interface Pin { - readonly location?: string; - readonly repositoryURL?: string; - readonly state?: PinState; -} +const record = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + +const text = (value: unknown): string | undefined => + typeof value === "string" ? value : undefined; -/** Reads every `Package.resolved` format: version 1 nests the pins under `object`. */ +/** + * Reads every `Package.resolved` format (version 1 nests the pins under + * `object`). Anything that is not the expected shape counts as no pin. + */ function stateIn(contents: string): PinState | undefined { - let parsed: { pins?: Pin[]; object?: { pins?: Pin[] } }; + let parsed: unknown; try { parsed = JSON.parse(contents); } catch { return undefined; } - const pins = parsed.pins ?? parsed.object?.pins ?? []; - return pins.find((pin) => - REPOSITORY.test(pin.location ?? pin.repositoryURL ?? ""), - )?.state; + const root = record(parsed); + const pins = root?.pins ?? record(root?.object)?.pins; + if (!Array.isArray(pins)) { + return undefined; + } + for (const entry of pins) { + const pin = record(entry); + const location = text(pin?.location) ?? text(pin?.repositoryURL) ?? ""; + if (REPOSITORY.test(location)) { + const state = record(pin?.state); + return { + version: text(state?.version), + branch: text(state?.branch), + revision: text(state?.revision), + }; + } + } + return undefined; } diff --git a/packages/typegen/test/swift.test.ts b/packages/typegen/test/swift.test.ts index f0a1eac..948e44b 100644 --- a/packages/typegen/test/swift.test.ts +++ b/packages/typegen/test/swift.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, @@ -131,6 +132,13 @@ if [ -n "$GIT_DIR$GIT_WORK_TREE$GIT_INDEX_FILE$GIT_OBJECT_DIRECTORY" ]; then fi case "$1" in --version) echo "git version 2.50.0" ;; + ls-remote) + if [ -n "$FAKE_TAGS" ]; then printf '%b' "$FAKE_TAGS" + else printf '%s\\trefs/tags/v3.0.0-alpha.1\\n' ${REVISION}; fi ;; + clone) + for argument; do directory=$argument; done + /bin/mkdir "$directory" ;; + merge-base) if [ -n "$FAKE_NOT_ON_BRANCH" ]; then exit 1; fi ;; init) /bin/mkdir .git ;; fetch) if [ -n "$FAKE_FETCH_FAIL" ]; then @@ -226,11 +234,12 @@ describe("swift source build", () => { return cwd; }; - const make = (platform = "darwin") => + const make = (platform = "darwin", userId = process.getuid?.()) => createSwift({ platform, temporaryDirectory: () => scratch, log: (message) => messages.push(message), + userId: () => userId, }); const run = ( @@ -270,6 +279,7 @@ describe("swift source build", () => { expect(commands()).toEqual([ "git --version", "swift --version", + "git ls-remote https://github.com/supabase/supabase-swift refs/tags/3.0.0-alpha.1 refs/tags/3.0.0-alpha.1^{} refs/tags/v3.0.0-alpha.1 refs/tags/v3.0.0-alpha.1^{}", "git init", `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, "git checkout --detach FETCH_HEAD", @@ -300,20 +310,114 @@ describe("swift source build", () => { expect(messages).toEqual([]); }); - test("builds a branch pin at its revision without a version check", async () => { + test("builds a branch pin once its revision is on that upstream branch", async () => { const cwd = project({ - "Package.resolved": pin({ branch: "main", revision: REVISION }), + "Package.resolved": pin({ branch: "release/v3", revision: REVISION }), }); expect(await run(cwd)).toBe("generated\n"); - expect(commands()).toContain( - `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, - ); + expect(commands().slice(2, 5)).toEqual([ + "git clone --bare --filter=tree:0 --no-tags --single-branch --branch release/v3 https://github.com/supabase/supabase-swift history.git", + `git merge-base --is-ancestor ${REVISION} HEAD`, + "git init", + ]); + expect(ran("git ls-remote")).toBe(false); expect(commands()).not.toContain("typegen --version"); expect(existsSync(cached())).toBe(true); }); + test("checks a bare revision pin against the default branch", async () => { + const cwd = project({ "Package.resolved": pin({ revision: REVISION }) }); + await run(cwd); + expect(commands()).toContain( + "git clone --bare --filter=tree:0 --no-tags --single-branch https://github.com/supabase/supabase-swift history.git", + ); + }); + + test("refuses a revision that is not on its upstream branch, such as a fork's commit", async () => { + const cwd = project({ + "Package.resolved": pin({ branch: "main", revision: REVISION }), + }); + const error = await rejection(run(cwd, { FAKE_NOT_ON_BRANCH: "1" })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + `${REVISION} is not on supabase-swift's main branch`, + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); + + test("accepts an annotated tag through its peeled commit", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const tag = "f".repeat(40); + expect( + await run(cwd, { + FAKE_TAGS: `${tag}\\trefs/tags/3.0.0-alpha.1\\n${REVISION}\\trefs/tags/3.0.0-alpha.1^{}\\n`, + }), + ).toBe("generated\n"); + }); + + test("refuses a version pin whose tag points at another commit", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + for (const tags of [ + `${"e".repeat(40)}\\trefs/tags/v3.0.0-alpha.1\\n`, + "", + ]) { + writeFileSync(logFile, ""); + const error = await rejection(run(cwd, { FAKE_TAGS: tags || "\\c" })); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + `supabase-swift's 3.0.0-alpha.1 tag does not point at ${REVISION}`, + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + } + }); + + test("refuses a malformed version or branch before running git", async () => { + const states: Record[] = [ + { version: "3.0.0\u001b]0;x\u0007", revision: REVISION }, + { version: "--upload-pack=x", revision: REVISION }, + { branch: "--upload-pack=x", revision: REVISION }, + { branch: "main:refs/heads/x", revision: REVISION }, + ]; + for (const state of states) { + const cwd = project({ "Package.resolved": pin(state) }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).not.toContain("\u001b"); + expect(commands()).toEqual([]); + } + }); + + test("skips a Package.resolved owned by another user", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const error = await rejection( + make("darwin", (process.getuid?.() ?? 0) + 1).generate( + unsortedMetadata, + {}, + createNodeHost({ cwd, env: { PATH: bin, FAKE_LOG: logFile } }), + ), + ); + expect(error).toBeInstanceOf(ToolNotInstalledError); + expect((error as ToolNotInstalledError).tool).toBe("supabase-typegen"); + expect(ran("git")).toBe(false); + }); + + test("skips malformed and non-regular Package.resolved files", async () => { + for (const contents of ["null", '{"pins":{}}', '{"pins":[null]}', "[1]"]) { + const cwd = project({ "Package.resolved": contents }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(ToolNotInstalledError); + } + const cwd = project({}); + rmSync(join(cwd, "Package.resolved"), { force: true }); + execFileSync("/usr/bin/mkfifo", [join(cwd, "Package.resolved")]); + expect(await rejection(run(cwd))).toBeInstanceOf(ToolNotInstalledError); + expect(commands()).toEqual([]); + }); + test("reads an Xcode project's Package.resolved in the version 1 format", async () => { const cwd = project({ "App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved": @@ -451,7 +555,12 @@ describe("swift source build", () => { test("reports a supabase-swift without supabase-typegen as not installed", async () => { const cwd = project({ "Package.resolved": versionPin("2.56.0") }); - const error = await rejection(run(cwd, { FAKE_NO_TYPEGEN: "1" })); + const error = await rejection( + run(cwd, { + FAKE_NO_TYPEGEN: "1", + FAKE_TAGS: `${REVISION}\\trefs/tags/2.56.0\\n`, + }), + ); expect(error).toBeInstanceOf(ToolNotInstalledError); expect((error as ToolNotInstalledError).message).toContain( "supabase-swift 2.56.0 has no supabase-typegen; update to 3.0.0-alpha.1 or newer", From 2f1e0ecac4096b6445623e1515f0542847631d5f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 12 Nov 2026 23:18:25 -0300 Subject: [PATCH 3/3] feat(typegen)!: pick the supabase-typegen source with swift-typegen-source supabase gen types --lang swift now picks the supabase-swift commit it builds supabase-typegen from in this order: the new swift-typegen-source option, the supabase-swift pin of the nearest Package.resolved, then the latest 3.x release with a notice on stderr. Every run prints the commit it uses. The option takes a local checkout (./, ../, / or ~/, built in place in its own .build folder and never cached), a full commit SHA, a version resolved through its tag, or a branch resolved with git ls-remote on every run. Abbreviated SHAs, other path forms, versions below 3 and branch names git could read as an option or a refspec are refused before git runs. The lockfile walk skips lockfiles without a supabase-swift pin and, when no parent has one, looks up to two levels below cwd, failing when those pins disagree. Without any pin it builds the highest stable 3.x release, or the highest 3.x prerelease before one exists. BREAKING CHANGE: SUPABASE_TYPEGEN and the supabase-typegen on PATH are no longer read; pass a local checkout to --swift-typegen-source instead. Windows, which only had the PATH fallback, is refused. Fixes SDK-3045 --- packages/typegen/CLAUDE.md | 29 +- packages/typegen/README.md | 101 ++- packages/typegen/src/languages/swift.ts | 792 +++++++++++++++++++----- packages/typegen/test/languages.test.ts | 6 + packages/typegen/test/swift.test.ts | 785 ++++++++++++++++++++--- 5 files changed, 1439 insertions(+), 274 deletions(-) diff --git a/packages/typegen/CLAUDE.md b/packages/typegen/CLAUDE.md index 5584c27..7968694 100644 --- a/packages/typegen/CLAUDE.md +++ b/packages/typegen/CLAUDE.md @@ -40,16 +40,25 @@ lives in supabase-js. `dart run supabase_typegen --output -`. No `--schema` flag: the document on stdin already holds the schemas the consumer introspected. - `src/languages/swift.ts` -- `supabase-typegen --access-control `, built - from source (SDK-3043) at the supabase-swift commit the nearest - `Package.resolved` pins, with the user's `git` and `swift`, and cached per - commit; then falls back to PATH; `SUPABASE_TYPEGEN` overrides both. Before - fetching it proves the commit is upstream's (tag for a version pin, ancestry - of the branch otherwise), since GitHub serves fork commits by SHA. The build - steps run through `host.spawn` with a temporary folder as `cwd`. The only - entry that touches the network or the file system; `createSwift` takes the - platform, the temporary directory and the progress `log` so tests stay - hermetic. `swift-access-control` maps to `--access-control` and accepts only - `internal` and `public`. Exit `65` is a rejected document. + from source (SDK-3043) at a supabase-swift commit picked in this order + (SDK-3045): the `swift-typegen-source` option (a local checkout starting with + `./`, `../`, `/` or `~/`, built in place and never cached; a full SHA; a + version, resolved through its tag; a branch, resolved on every run, all with + `git ls-remote`), the supabase-swift pin of the nearest `Package.resolved` + that has one (`cwd` and parents, then up to two levels down, where the pins + must agree), or the latest 3.x release with a notice. Each run logs the commit + it uses. The binary is cached per commit. There is no PATH fallback and no + `SUPABASE_TYPEGEN`; Windows is refused. Before fetching a commit that did not + come from upstream's own refs it proves the commit is upstream's (tag for a + version pin, ancestry of the branch otherwise), since GitHub serves fork + commits by SHA. The value parsing, `git ls-remote` parsing and semver + selection are pure exported functions (`parseTypegenSource`, `parseRefs`, + `compareVersions`, `latestRelease`) with direct tests. The build steps run + through `host.spawn` with a temporary folder as `cwd`. The only entry that + touches the network or the file system; `createSwift` takes the platform, the + temporary directory and the progress `log` so tests stay hermetic. + `swift-access-control` maps to `--access-control` and accepts only `internal` + and `public`. Exit `65` is a rejected document. - `src/languages/index.ts` -- the three in-process entries and the `languages` list. `typescript` has no user options; `detect-one-to-one-relationships` (default true; the CLI's deprecated `--postgrest-v9-compat` sets it false), diff --git a/packages/typegen/README.md b/packages/typegen/README.md index 7b68a46..938e1df 100644 --- a/packages/typegen/README.md +++ b/packages/typegen/README.md @@ -62,7 +62,7 @@ not. | `typescript` | in-process | `generateTypescript` from `@supabase/postgrest-typegen` | consumer: `detect-one-to-one-relationships`, `postgrest-version`, `default-schema` | | `go` | in-process | `generateGo` from `@supabase/postgrest-typegen` | | | `python` | in-process | `generatePython` from `@supabase/postgrest-typegen` | | -| `swift` | out-of-process | `supabase-typegen --access-control `, built | `--swift-access-control internal\|public` | +| `swift` | out-of-process | `supabase-typegen --access-control `, built | `--swift-access-control internal\|public`, `--swift-typegen-source ` | | `dart` | out-of-process | `dart run supabase_typegen --output -` in the project | | The three in-process entries are a transition: as each generator relocates to @@ -76,28 +76,71 @@ swift-format dependency would pin every app's swift-syntax, and an Xcode project without a `Package.swift` has nothing to `swift run` from. The output targets one commit's `@Table` macro, so the generator must match the -project's supabase-swift. The entry reads the supabase-swift pin from the -nearest `Package.resolved` (in `cwd` or a parent; SwiftPM's, or Xcode's inside -the `.xcodeproj` or `.xcworkspace`) and builds the generator from source at the -pin's `revision`, so version, branch and revision pins all work: - -1. The revision must be a 40-character lowercase hex SHA, a version must be a - semantic version and a branch a plain branch name; nothing else reaches - `git`. Only regular files the user owns count as `Package.resolved`, so - another user's file in a shared parent such as `/tmp` cannot pick the commit, - and a malformed one counts as no pin. +project's supabase-swift. The entry builds it from source at a supabase-swift +commit, which it picks in this order: + +1. `--swift-typegen-source`, when given. The value is read in a fixed order, + without guessing: + - A value that starts with `./`, `../`, `/` or `~/` is a local checkout. + Relative paths must start with `./` or `../`, so a folder named `3.0.0` is + never read as a version, and `~` means the home folder. The entry checks + that the checkout has `tools/supabase-typegen`, runs + `swift build -c release --package-path /tools/supabase-typegen` + in the checkout's own `.build` folder, so later builds are incremental, and + runs the binary from there. It never caches it, since the working tree can + hold uncommitted changes. This is the form for working on the generator, or + for a fork. + - 40 hex characters are a commit SHA. An abbreviated SHA is refused. + - A semantic version, with or without a leading `v`, is the commit its tag + `v` (or ``) points at, per `git ls-remote`; an annotated + tag's commit wins. A version below 3 is refused, since supabase-swift 2.x + has no generator. + - Anything else is a branch, resolved to its commit with `git ls-remote` on + every run, so new commits are picked up. A branch name that starts with `-` + or holds whitespace, `..`, control characters or anything else git could + read as an option or a refspec is refused before git runs. + + A lookup that finds nothing names what it looked for, and for a branch name + that is also a folder in `cwd`, the `./` form to build it. + +2. The supabase-swift pin of the nearest `Package.resolved` that has one: in + `cwd` or a parent, SwiftPM's or Xcode's inside the `.xcodeproj` or + `.xcworkspace`. Lockfiles without a supabase-swift pin are skipped. For a + monorepo run from its root, when no parent has a pin, the entry also looks up + to two levels below `cwd`, skipping `.git`, `.build`, `node_modules`, `Pods` + and `DerivedData`; the pins found there must agree on the revision, or the + run fails and asks for `--swift-typegen-source`. The pin's `revision` is + built, so version, branch and revision pins all work. +3. The latest supabase-swift 3.x release from `git ls-remote --tags`: the + highest stable 3.x once there is one, the highest 3.x prerelease before that + (with semantic version precedence, so `alpha.10` follows `alpha.9`), and + never another major version. A notice on stderr says so and asks to run from + the app's directory, or to pass `--swift-typegen-source`, to match the pinned + version. + +Every run prints the commit it uses to stderr, for example +``Using branch `main` at `4f2a9c1…`.``. Except for a local checkout, the build +then goes: + +1. Only a 40-character lowercase hex SHA reaches `git` as a revision, a version + must be a semantic version and a branch a plain branch name. Only regular + files the user owns count as `Package.resolved`, so another user's file in a + shared parent such as `/tmp` cannot pick the commit, and a malformed one + counts as no pin. 2. A binary already in `$XDG_CACHE_HOME/supabase/typegen//` (default `~/Library/Caches` on macOS, `~/.cache` elsewhere; an empty or relative `XDG_CACHE_HOME` is ignored) runs with no build. 3. Otherwise it checks that `git` and `swift` (Swift 6.2 / Xcode 26 or newer) - are on PATH and that the commit is upstream's. GitHub serves every commit of - the fork network through the upstream URL, pull request heads included, so a - version pin's tag (`` or `v`, per `git ls-remote`) must - point at the revision, and a branch pin's revision must be an ancestor of - that upstream branch (a bare revision pin: the default branch), checked in a - commits-only clone (`--filter=tree:0`, about 1.5 MB and a second). It then - fetches the single commit into a temporary folder, checks that `HEAD` is the - revision, and runs + are on PATH and, for a commit that did not come from upstream's own tags or + branches, that it is upstream's. GitHub serves every commit of the fork + network through the upstream URL, pull request heads included, so a version + pin's tag (`` or `v`, per `git ls-remote`) must point at + the revision, and a branch pin's revision must be an ancestor of that + upstream branch (a bare revision pin, or a SHA passed to + `--swift-typegen-source`: the default branch), checked in a commits-only + clone (`--filter=tree:0`, about 1.5 MB and a second). It then fetches the + single commit into a temporary folder, checks that `HEAD` is the revision, + and runs `swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution`. The flag builds the generator's committed `Package.resolved` exactly and fails rather than resolve other dependency versions. Linux adds @@ -106,19 +149,23 @@ pin's `revision`, so version, branch and revision pins all work: (`GIT_DIR`, `GIT_INDEX_FILE` and the rest of `git rev-parse --local-env-vars`) are removed from these steps, so running inside a git hook cannot touch the user's repository. -4. For a version pin, `supabase-typegen --version` must print that version. +4. For a version, from the pin, the flag or the latest release, + `supabase-typegen --version` must print that version. 5. The binary is copied beside its cache path and renamed into place, so concurrent runs are safe. The temporary folder is removed in every case. The first build prints a line to stderr and takes a few minutes, mostly compiling swift-syntax: about 5.5 minutes on an Apple M3 with Swift 6.4, and -about the same on Linux aarch64 with Swift 6.2 in Docker on that machine. It -trusts nothing the app does not already run: the supabase-swift source at the -pinned commit, the generator's pinned dependencies and the user's toolchain. -supabase-swift 2.x has no `tools/supabase-typegen` and fails with a request to -update to 3.0.0-alpha.1 or newer. Without a supabase-swift pin, or on Windows, -the entry runs `supabase-typegen` from PATH. `SUPABASE_TYPEGEN=` overrides -both. +about the same on Linux aarch64 with Swift 6.2 in Docker on that machine. A +pinned build trusts nothing the app does not already run: the supabase-swift +source at the pinned commit, the generator's pinned dependencies and the user's +toolchain. A version or the latest release trusts the tag at the time of the +run, and a branch anyone who can push to it, which is why a branch is never a +default. supabase-swift 2.x has no `tools/supabase-typegen` and fails with a +request to update to 3.0.0-alpha.1 or newer. The entry never runs a +`supabase-typegen` from PATH, and `SUPABASE_TYPEGEN` is gone: a stale binary +could generate code for the wrong version without a warning, and a local +checkout covers the development loop. Windows is not supported. `--swift-access-control` lost `private` and `package`, which the new generator does not accept. diff --git a/packages/typegen/src/languages/swift.ts b/packages/typegen/src/languages/swift.ts index dcef7c2..3963e74 100644 --- a/packages/typegen/src/languages/swift.ts +++ b/packages/typegen/src/languages/swift.ts @@ -11,15 +11,24 @@ import { stat, } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join } from "node:path"; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, +} from "node:path"; import type { ChoiceOptionSpec, Host, SpawnRequest, SpawnResult, + StringOptionSpec, TypegenLanguage, } from "../contract.ts"; import { + InvalidOptionError, ToolFailedError, ToolNotInstalledError, TypegenError, @@ -28,18 +37,33 @@ import { resolveOptions } from "../options.ts"; import { externalLanguage, isCommandNotFound } from "./external.ts"; const SWIFT_ACCESS_CONTROL = "swift-access-control"; +const SWIFT_TYPEGEN_SOURCE = "swift-typegen-source"; const BINARY = "supabase-typegen"; const SOURCE = "https://github.com/supabase/supabase-swift"; const REPOSITORY = /github\.com[/:]supabase\/supabase-swift(\.git)?\/?$/i; const PACKAGE_PATH = "tools/supabase-typegen"; const TOOLCHAIN = "Swift 6.2 / Xcode 26"; const FIRST_RELEASE = "3.0.0-alpha.1"; +/** The major version whose latest release is the default without a pin. */ +const MAJOR = 3; /** The only revision form ever passed to git, so a pin cannot smuggle in an option. */ const COMMIT = /^[0-9a-f]{40}$/; -/** Semantic versions only: a pinned version reaches git as a tag name and the terminal as text. */ +/** Hex that could be an abbreviated commit; git abbreviates to 7 characters by default. */ +const SHORT_COMMIT = /^[0-9a-f]{7,39}$/i; +/** Semantic versions only: a version reaches git as a tag name and the terminal as text. */ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; /** Branch names git accepts, minus anything that could read as an option or a refspec. */ const BRANCH = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]+$/; +/** How far below `cwd` a monorepo's lockfiles are searched when no parent has one. */ +const SEARCH_DEPTH = 2; +/** Folders that hold dependencies, build output or history, never an app's lockfile. */ +const SKIPPED_FOLDERS = new Set([ + ".git", + ".build", + "node_modules", + "Pods", + "DerivedData", +]); /** * The flag `supabase gen types` already exposes, narrowed to the two levels @@ -55,14 +79,22 @@ const swiftAccessControl = { help: "Access control for Swift generated types.", } satisfies ChoiceOptionSpec; -const installHint = `Install git and ${TOOLCHAIN} or newer so \`${BINARY}\` can be built from the supabase-swift commit your project pins, or set SUPABASE_TYPEGEN to a \`${BINARY}\` binary, or put one on PATH.`; +/** Which supabase-swift source `supabase-typegen` is built from. */ +const swiftTypegenSource = { + name: SWIFT_TYPEGEN_SOURCE, + audience: "user", + kind: "string", + help: "Where to build supabase-typegen from: a supabase-swift version (3.0.0-alpha.1), a branch (main), a full commit SHA, or a local checkout (./supabase-swift). Default: the supabase-swift pin in the nearest Package.resolved, else the latest 3.x release.", +} satisfies StringOptionSpec; + +const installHint = `Install git and ${TOOLCHAIN} or newer, which build \`${BINARY}\` from supabase-swift's source. Pass --${SWIFT_TYPEGEN_SOURCE} to choose that source: a version, a branch, a full commit SHA or a local checkout.`; export interface SwiftDependencies { - /** `process.platform`. Windows skips the source build. */ + /** `process.platform`. Windows is not supported. */ readonly platform: string; - /** Where the source build makes its scratch checkout. */ + /** Where the source build makes its scratch checkout and runs its lookups. */ readonly temporaryDirectory: () => string; - /** Progress the user should see while the generator builds. */ + /** Progress the user should see while the generator is chosen and built. */ readonly log: (message: string) => void; /** * `process.getuid()`. A `Package.resolved` owned by anyone else is ignored, @@ -77,25 +109,27 @@ export interface SwiftDependencies { * on stdin and writes one file of `@Table` structs to stdout. * * The generated code targets the `@Table` macro of one supabase-swift - * commit, so the generator must come from the same commit as the project's - * dependency. The entry reads that commit from the nearest `Package.resolved` - * (SwiftPM, or Xcode's copy inside the `.xcodeproj` or `.xcworkspace`), - * builds the generator there from source once with the user's own `git` and - * `swift`, caches the binary per commit and runs it. Without a supabase-swift - * pin, or on Windows, it runs `supabase-typegen` from PATH. - * `SUPABASE_TYPEGEN` overrides both. + * commit, so the generator is built from source at a supabase-swift commit + * with the user's own `git` and `swift`, and cached per commit. The commit + * is, in this order: the one `swift-typegen-source` names (a version, a + * branch, a full SHA, or a local checkout built in place and never cached); + * the supabase-swift pin of the nearest `Package.resolved` (SwiftPM, or + * Xcode's copy inside the `.xcodeproj` or `.xcworkspace`), in `cwd`, a parent, + * or up to two levels below `cwd`; or the latest 3.x release. * - * A source build trusts nothing beyond what the app already runs: the + * A pinned build trusts nothing beyond what the app already runs: the * supabase-swift source at the pinned commit, the generator's committed * `Package.resolved`, and the user's toolchain. GitHub serves every commit of * the fork network, pull request heads included, through the upstream URL, so - * the commit must first be shown to be upstream's: a version pin's tag must - * point at it, and any other pin must be on its upstream branch. It is a separate executable, - * not a package dependency, because its swift-format dependency would pin - * every app's swift-syntax, and because an Xcode project without a - * `Package.swift` has nothing to `swift run` from. No `--schema` is passed: - * the document already holds exactly the schemas the consumer introspected, - * and the tool generates all of them. + * a commit that did not come from upstream's own tags or branches must first + * be shown to be upstream's: a version pin's tag must point at it, and any + * other pin, or a SHA passed to `swift-typegen-source`, must be on its + * upstream branch. It is a separate executable, not a package dependency, + * because its swift-format dependency would pin every app's swift-syntax, and + * because an Xcode project without a `Package.swift` has nothing to + * `swift run` from. No `--schema` is passed: the document already holds + * exactly the schemas the consumer introspected, and the tool generates all + * of them. */ export function createSwift( dependencies: SwiftDependencies = { @@ -105,19 +139,23 @@ export function createSwift( userId: () => process.getuid?.(), }, ): TypegenLanguage { - const options = [swiftAccessControl]; + const options = [swiftAccessControl, swiftTypegenSource]; return { name: "swift", inProcess: false, options, async generate(metadata, values, host) { - resolveOptions("swift", options, values); - const command = await resolveBinary(host, dependencies); + const resolved = resolveOptions("swift", options, values); + const command = await resolveBinary( + host, + dependencies, + resolved[SWIFT_TYPEGEN_SOURCE] as string | undefined, + ); return externalLanguage("swift", options, { command, - args: (_metadata, resolved) => [ + args: (_metadata, resolvedOptions) => [ "--access-control", - resolved[SWIFT_ACCESS_CONTROL] as string, + resolvedOptions[SWIFT_ACCESS_CONTROL] as string, ], installHint, classify: (result) => @@ -129,28 +167,429 @@ export function createSwift( export const swift = createSwift(); +/** A value of `swift-typegen-source`, parsed without looking anything up. */ +export type TypegenSource = + | { readonly kind: "path"; readonly path: string } + | { readonly kind: "commit"; readonly revision: string } + | { readonly kind: "version"; readonly version: string } + | { readonly kind: "branch"; readonly branch: string }; + +/** + * Reads a `swift-typegen-source` value in a fixed order, without guessing: a + * path starts with `./`, `../`, `/` or `~/` (so a folder named `3.0.0` is + * never read as a version), a commit is 40 hex characters, a version is a + * semantic version with or without a leading `v`, and anything else that is a + * plain branch name is a branch. Throws an `InvalidOptionError` for an + * abbreviated SHA, any other path form and anything git could read as an + * option or a refspec. + */ +export function parseTypegenSource( + value: string, + base: { readonly cwd: string; readonly home: string }, +): TypegenSource { + const invalid = (reason: string) => + new InvalidOptionError({ + language: "swift", + option: SWIFT_TYPEGEN_SOURCE, + message: `--${SWIFT_TYPEGEN_SOURCE} ${printable(value)} ${reason}`, + }); + if (/^[./~]/.test(value)) { + if (value === "~" || value.startsWith("~/")) { + return { kind: "path", path: join(base.home, value.slice(1)) }; + } + if ( + value.startsWith("/") || + value === "." || + value === ".." || + value.startsWith("./") || + value.startsWith("../") + ) { + return { kind: "path", path: resolve(base.cwd, value) }; + } + throw invalid( + "is not a path this option reads. Start a relative path with ./ or ../, and a path in your home folder with ~/.", + ); + } + if (/^[0-9a-f]{40}$/i.test(value)) { + return { kind: "commit", revision: value.toLowerCase() }; + } + if (SHORT_COMMIT.test(value)) { + throw invalid( + "looks like an abbreviated commit SHA. Pass the full 40-character SHA.", + ); + } + const version = value.replace(/^v(?=\d)/, ""); + if (VERSION.test(version)) { + return { kind: "version", version }; + } + if (!BRANCH.test(value)) { + throw invalid( + "is not a version, a full commit SHA, a path starting with ./, ../, / or ~/, or a valid branch name.", + ); + } + return { kind: "branch", branch: value }; +} + +/** + * `git ls-remote` output as ref name to commit. An annotated tag lists its + * own object and, as `^{}`, the commit it points at; the commit wins. + * Lines whose object is not a full SHA are dropped. + */ +export function parseRefs(listing: string): Map { + const refs = new Map(); + const peeled = new Map(); + for (const line of listing.split("\n")) { + const [sha, ref] = line.split("\t").map((field) => field.trim()); + if (sha === undefined || ref === undefined || !COMMIT.test(sha)) { + continue; + } + if (ref.endsWith("^{}")) { + peeled.set(ref.slice(0, -3), sha); + } else { + refs.set(ref, sha); + } + } + for (const [ref, sha] of peeled) { + refs.set(ref, sha); + } + return refs; +} + +interface SemanticVersion { + readonly core: readonly [number, number, number]; + readonly prerelease: readonly string[]; +} + +const parseVersion = (version: string): SemanticVersion | undefined => { + const match = + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec( + version, + ); + if (!match) { + return undefined; + } + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4]?.split(".") ?? [], + }; +}; + +const compareIdentifiers = (a: string, b: string): number => { + const numeric = /^\d+$/; + if (numeric.test(a) && numeric.test(b)) { + return Number(a) - Number(b); + } + if (numeric.test(a) !== numeric.test(b)) { + return numeric.test(a) ? -1 : 1; + } + return a < b ? -1 : a > b ? 1 : 0; +}; + +/** + * Semantic version precedence: negative when `a` comes before `b`. A + * prerelease comes before its release, and prerelease identifiers compare + * numerically when both are numbers, so `alpha.10` follows `alpha.9`. Build + * metadata is ignored. Both arguments must be semantic versions. + */ +export function compareVersions(a: string, b: string): number { + const left = parseVersion(a); + const right = parseVersion(b); + if (!left || !right) { + throw new RangeError(`Not semantic versions: ${a}, ${b}`); + } + for (const index of [0, 1, 2] as const) { + const difference = left.core[index] - right.core[index]; + if (difference !== 0) { + return difference; + } + } + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + return right.prerelease.length - left.prerelease.length; + } + const shared = Math.min(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < shared; index++) { + const difference = compareIdentifiers( + left.prerelease[index] ?? "", + right.prerelease[index] ?? "", + ); + if (difference !== 0) { + return difference; + } + } + return left.prerelease.length - right.prerelease.length; +} + +/** + * The tag of the highest `major`.x release among `tags` (names with or + * without a leading `v`): the highest stable release once there is one, the + * highest prerelease before that. Never another major version. + */ +export function latestRelease( + tags: readonly string[], + major: number, +): { readonly tag: string; readonly version: string } | undefined { + const releases = tags.flatMap((tag) => { + const version = tag.replace(/^v(?=\d)/, ""); + const parsed = parseVersion(version); + return parsed?.core[0] === major ? [{ tag, version, parsed }] : []; + }); + const stable = releases.filter( + (release) => release.parsed.prerelease.length === 0, + ); + const candidates = stable.length > 0 ? stable : releases; + let latest: (typeof candidates)[number] | undefined; + for (const candidate of candidates) { + if ( + latest === undefined || + compareVersions(candidate.version, latest.version) > 0 + ) { + latest = candidate; + } + } + return latest && { tag: latest.tag, version: latest.version }; +} + +/** The supabase-swift commit to build, already a 40-character lowercase hex SHA. */ +interface Target { + readonly revision: string; + /** Set when a version was asked for; the built binary must report it. */ + readonly version?: string; + /** + * How to show the revision is upstream's before fetching it: the version + * tag must point at it, or it must be on the branch (`undefined`: the + * default branch). Absent when the revision came from upstream's own refs. + */ + readonly provenance?: + | { readonly kind: "tag"; readonly version: string } + | { readonly kind: "branch"; readonly branch?: string }; +} + +/** The `git` and `swift` steps, which name the step and its tool when they fail. */ +interface Runner { + exec( + command: string, + args: readonly string[], + cwd: string, + step: string, + ): Promise; + /** Like `exec`, but a non-zero exit is a `ToolFailedError` with the tool's output. */ + run( + command: string, + args: readonly string[], + cwd: string, + step: string, + ): Promise; +} + +function createRunner(host: Host, spawn: NonNullable): Runner { + const exec: Runner["exec"] = async (command, args, cwd, step) => { + const request: SpawnRequest = { + command, + args, + cwd, + env: buildEnvironment(host.env), + stdin: "", + signal: host.signal, + }; + try { + return await spawn(request); + } catch (error) { + if (isCommandNotFound(error)) { + throw new ToolNotInstalledError({ + language: "swift", + tool: command, + installHint, + message: `${step} needs \`${command}\`, which was not found on PATH. ${installHint}`, + cause: error, + }); + } + throw error; + } + }; + return { + exec, + async run(command, args, cwd, step) { + const result = await exec(command, args, cwd, step); + if (result.exitCode !== 0) { + const commandLine = [command, ...args]; + const output = [result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n"); + throw new ToolFailedError({ + language: "swift", + command: commandLine, + exitCode: result.exitCode, + stderr: result.stderr.trim(), + message: `${step} failed: \`${commandLine.join(" ")}\` ${ + result.exitCode === null + ? "was terminated by a signal" + : `exited with code ${result.exitCode}` + }.${output ? `\n${output}` : ""}`, + }); + } + return result; + }, + }; +} + async function resolveBinary( host: Host, dependencies: SwiftDependencies, + source: string | undefined, ): Promise { - const override = host.env.SUPABASE_TYPEGEN; - if (override) { - return override; - } const { spawn } = host; // Without spawn, externalLanguage reports SpawnUnavailableError for the generator itself. - if (dependencies.platform === "win32" || spawn === undefined) { + if (spawn === undefined) { return BINARY; } - const pin = await pinnedState(host.cwd, dependencies.userId()); - if (pin?.revision === undefined) { - return BINARY; + if (dependencies.platform === "win32") { + throw new TypegenError({ + language: "swift", + message: `Generating Swift types builds \`${BINARY}\` from supabase-swift's source, which is not supported on Windows. Run supabase gen types --lang swift on macOS or Linux.`, + }); + } + const runner = createRunner(host, spawn); + let target: Target; + if (source === undefined) { + target = await defaultTarget(host, dependencies, runner); + } else { + const parsed = parseTypegenSource(source, { + cwd: host.cwd, + home: absolute(host.env.HOME) ?? homedir(), + }); + if (parsed.kind === "path") { + return await buildCheckout(parsed.path, dependencies, runner); + } + target = await chosenTarget(parsed, host, dependencies, runner); } - const { revision, version, branch } = pin; + const path = join( + cacheDirectory(host.env, dependencies.platform), + target.revision, + BINARY, + ); + if (!existsSync(path)) { + await build({ host, dependencies, runner, target, path }); + } + return path; +} + +const abbreviated = (revision: string) => `\`${revision.slice(0, 7)}…\``; + +/** Resolves a version, branch or commit from `swift-typegen-source` to a commit. */ +async function chosenTarget( + source: Exclude, + host: Host, + dependencies: SwiftDependencies, + runner: Runner, +): Promise { + const lookups = dependencies.temporaryDirectory(); + switch (source.kind) { + case "commit": + dependencies.log(`Using commit ${abbreviated(source.revision)}.`); + return { + revision: source.revision, + provenance: { kind: "branch" }, + }; + case "version": { + const { version } = source; + if ((parseVersion(version)?.core[0] ?? 0) < MAJOR) { + throw new InvalidOptionError({ + language: "swift", + option: SWIFT_TYPEGEN_SOURCE, + message: `supabase-swift ${version} has no ${BINARY}; pass ${FIRST_RELEASE} or newer to --${SWIFT_TYPEGEN_SOURCE}.`, + }); + } + const tags = [`v${version}`, version].map((name) => `refs/tags/${name}`); + const listed = await runner.run( + "git", + ["ls-remote", SOURCE, ...tags.flatMap((tag) => [tag, `${tag}^{}`])], + lookups, + `Looking up supabase-swift's tag v${version}`, + ); + const refs = parseRefs(listed.stdout); + const revision = tags + .map((tag) => refs.get(tag)) + .find((sha) => sha !== undefined); + if (revision === undefined) { + throw new TypegenError({ + language: "swift", + message: `No tag named \`v${version}\` in supabase/supabase-swift (looked for ${tags.join(" and ")}).`, + }); + } + dependencies.log( + `Using version \`${version}\` at ${abbreviated(revision)}.`, + ); + return { revision, version }; + } + case "branch": { + const { branch } = source; + const ref = `refs/heads/${branch}`; + const listed = await runner.run( + "git", + ["ls-remote", SOURCE, ref], + lookups, + `Looking up supabase-swift's branch ${branch}`, + ); + const revision = parseRefs(listed.stdout).get(ref); + if (revision === undefined) { + const folder = resolve(host.cwd, branch); + throw new TypegenError({ + language: "swift", + message: `No branch named \`${branch}\` in supabase/supabase-swift.${ + existsSync(folder) + ? ` To build the local checkout at ${folder}, pass ./${branch}.` + : "" + }`, + }); + } + dependencies.log( + `Using branch \`${branch}\` at ${abbreviated(revision)}.`, + ); + return { revision }; + } + } +} + +/** The lockfile pin, or else the latest 3.x release with a notice. */ +async function defaultTarget( + host: Host, + dependencies: SwiftDependencies, + runner: Runner, +): Promise { + const pin = await findPin(host.cwd, dependencies.userId()); + if (pin === undefined) { + const listed = await runner.run( + "git", + ["ls-remote", "--tags", SOURCE], + dependencies.temporaryDirectory(), + "Looking up supabase-swift's releases", + ); + const refs = parseRefs(listed.stdout); + const prefix = "refs/tags/"; + const latest = latestRelease( + [...refs.keys()] + .filter((ref) => ref.startsWith(prefix)) + .map((ref) => ref.slice(prefix.length)), + MAJOR, + ); + const revision = latest && refs.get(`${prefix}${latest.tag}`); + if (latest === undefined || revision === undefined) { + throw new TypegenError({ + language: "swift", + message: `No supabase-swift pin found from \`${host.cwd}\`, and supabase/supabase-swift has no ${MAJOR}.x release yet. Run from your app directory, or pass --${SWIFT_TYPEGEN_SOURCE}.`, + }); + } + dependencies.log( + `No supabase-swift pin found from \`${host.cwd}\`. Using ${latest.version} (latest) at ${abbreviated(revision)}. Run from your app directory, or pass --${SWIFT_TYPEGEN_SOURCE}, to match your pinned version.`, + ); + return { revision, version: latest.version }; + } + const { revision, version, branch, file } = pin; + const where = relative(host.cwd, file) || file; const invalid = (field: string, value: string, expected: string) => new TypegenError({ language: "swift", - message: `The supabase-swift pin in ${host.cwd}'s Package.resolved has ${field} ${JSON.stringify(value)}, which is not ${expected}. Resolve the package again, or: ${installHint}`, + message: `The supabase-swift pin in ${where} has ${field} ${printable(value)}, which is not ${expected}. Resolve the package again, or pass --${SWIFT_TYPEGEN_SOURCE}.`, }); if (!COMMIT.test(revision)) { throw invalid( @@ -165,107 +604,82 @@ async function resolveBinary( if (version === undefined && branch !== undefined && !BRANCH.test(branch)) { throw invalid("branch", branch, "a plain branch name"); } - const path = join( - cacheDirectory(host.env, dependencies.platform), - revision, - BINARY, + if (version !== undefined) { + dependencies.log( + `Using version \`${version}\` at ${abbreviated(revision)}, pinned in ${where}.`, + ); + return { revision, version, provenance: { kind: "tag", version } }; + } + dependencies.log( + branch === undefined + ? `Using commit ${abbreviated(revision)}, pinned in ${where}.` + : `Using branch \`${branch}\` at ${abbreviated(revision)}, pinned in ${where}.`, ); - if (!existsSync(path)) { - await build({ - host, - spawn, - dependencies, - pin: { revision, version, branch }, - path, + return { revision, provenance: { kind: "branch", branch } }; +} + +/** + * Builds the generator in a local supabase-swift checkout, in its own + * `.build` folder so later builds are incremental, and returns the binary + * there. Never cached: the working tree can hold uncommitted changes. + */ +async function buildCheckout( + checkout: string, + dependencies: SwiftDependencies, + runner: Runner, +): Promise { + const packagePath = join(checkout, PACKAGE_PATH); + if (!existsSync(join(packagePath, "Package.swift"))) { + throw new InvalidOptionError({ + language: "swift", + option: SWIFT_TYPEGEN_SOURCE, + message: `${checkout} has no ${PACKAGE_PATH}. Pass --${SWIFT_TYPEGEN_SOURCE} a checkout of supabase-swift ${FIRST_RELEASE} or newer.`, }); } - return path; + dependencies.log( + `Using the local checkout ${checkout}, with any uncommitted changes. Building ${BINARY} there; the first build takes a few minutes, later ones rebuild only what changed.`, + ); + await runner.run( + "swift", + ["build", "-c", "release", "--package-path", packagePath], + checkout, + `Building \`${BINARY}\` in ${checkout}`, + ); + return join(packagePath, ".build", "release", BINARY); } interface Build { readonly host: Host; - readonly spawn: NonNullable; readonly dependencies: SwiftDependencies; - /** A validated pin: the revision is a SHA, the version a semantic version, the branch plain. */ - readonly pin: { readonly revision: string } & PinState; + readonly runner: Runner; + readonly target: Target; /** Where the finished binary goes in the cache. */ readonly path: string; } /** - * Checks that `pin.revision` is upstream's, checks it out into a scratch - * folder, builds the generator with its committed dependency versions and - * moves the binary to `path`. The scratch folder is removed whatever happens, - * and the cache only ever receives a binary that built and, for a version pin, - * reports that version. + * Checks that `target.revision` is upstream's when it did not come from + * upstream's refs, checks it out into a scratch folder, builds the generator + * with its committed dependency versions and moves the binary to `path`. The + * scratch folder is removed whatever happens, and the cache only ever + * receives a binary that built and, for a version, reports that version. */ async function build({ host, - spawn, dependencies, - pin, + runner, + target, path, }: Build): Promise { - const { revision, version, branch } = pin; - const label = version ?? revision; - const exec = async ( - command: string, - args: readonly string[], - cwd: string, - ): Promise => { - const request: SpawnRequest = { - command, - args, - cwd, - env: buildEnvironment(host.env), - stdin: "", - signal: host.signal, - }; - try { - return await spawn(request); - } catch (error) { - if (isCommandNotFound(error)) { - throw new ToolNotInstalledError({ - language: "swift", - tool: command, - installHint, - message: `Building \`${BINARY}\` ${label} from source needs \`${command}\` and ${TOOLCHAIN} or newer, and \`${command}\` was not found on PATH. ${installHint}`, - cause: error, - }); - } - throw error; - } - }; - const run = async ( - command: string, - args: readonly string[], - cwd: string, - ): Promise => { - const result = await exec(command, args, cwd); - if (result.exitCode !== 0) { - const commandLine = [command, ...args]; - const output = [result.stdout.trim(), result.stderr.trim()] - .filter(Boolean) - .join("\n"); - throw new ToolFailedError({ - language: "swift", - command: commandLine, - exitCode: result.exitCode, - stderr: result.stderr.trim(), - message: `Building \`${BINARY}\` ${label} from source failed: \`${commandLine.join(" ")}\` ${ - result.exitCode === null - ? "was terminated by a signal" - : `exited with code ${result.exitCode}` - }.${output ? `\n${output}` : ""}`, - }); - } - return result; - }; + const { revision, version, provenance } = target; + const step = `Building \`${BINARY}\` ${version ?? revision} from source`; + const run = (command: string, args: readonly string[], cwd: string) => + runner.run(command, args, cwd, step); await run("git", ["--version"], host.cwd); await run("swift", ["--version"], host.cwd); dependencies.log( - `Building ${BINARY} ${label} from source (supabase-swift ${revision}). This takes a few minutes; later runs use the cached build.`, + `Building ${BINARY} ${version ?? revision} from source (supabase-swift ${revision}). This takes a few minutes; later runs use the cached build.`, ); const scratch = await mkdtemp( join(dependencies.temporaryDirectory(), `${BINARY}-`), @@ -274,31 +688,25 @@ async function build({ const partial = `${path}.${process.pid}.${basename(scratch)}.partial`; const checkout = join(scratch, "src"); try { - if (version !== undefined) { + if (provenance?.kind === "tag") { // A tag can only come from upstream's maintainers; annotated tags list the commit as `^{}`. - const tags = [version, `v${version}`].map((name) => `refs/tags/${name}`); + const tags = [provenance.version, `v${provenance.version}`].map( + (name) => `refs/tags/${name}`, + ); const listed = await run( "git", ["ls-remote", SOURCE, ...tags.flatMap((tag) => [tag, `${tag}^{}`])], scratch, ); - const commits = new Map( - listed.stdout - .split("\n") - .map((line) => line.split("\t")) - .map(([sha, ref]) => [ref?.trim(), sha?.trim()]), - ); - if ( - !tags.some( - (tag) => (commits.get(`${tag}^{}`) ?? commits.get(tag)) === revision, - ) - ) { + const refs = parseRefs(listed.stdout); + if (!tags.some((tag) => refs.get(tag) === revision)) { throw new TypegenError({ language: "swift", - message: `supabase-swift's ${version} tag does not point at ${revision}, the revision Package.resolved pins. Resolve the package again; a revision from a fork or a pull request is not built.`, + message: `supabase-swift's ${provenance.version} tag does not point at ${revision}, the revision Package.resolved pins. Resolve the package again; a revision from a fork or a pull request is not built.`, }); } - } else { + } else if (provenance?.kind === "branch") { + const { branch } = provenance; // Only the commits of one branch, about 1.5 MB, to show the revision is on it. const branchArgs = branch === undefined ? [] : ["--branch", branch]; await run( @@ -315,15 +723,16 @@ async function build({ ], scratch, ); - const ancestry = await exec( + const ancestry = await runner.exec( "git", ["merge-base", "--is-ancestor", revision, "HEAD"], join(scratch, "history.git"), + step, ); if (ancestry.exitCode !== 0) { throw new TypegenError({ language: "swift", - message: `${revision} is not on supabase-swift's ${branch ?? "default"} branch. Resolve the package again; a revision from a fork or a pull request is not built.`, + message: `${revision} is not on supabase-swift's ${branch ?? "default"} branch, and a revision from a fork or a pull request is not built. Resolve the package again if the branch moved, or pass a local checkout to --${SWIFT_TYPEGEN_SOURCE}.`, }); } } @@ -343,7 +752,7 @@ async function build({ language: "swift", tool: BINARY, installHint: `Update supabase-swift to ${FIRST_RELEASE} or newer.`, - message: `supabase-swift ${label} has no ${BINARY}; update to ${FIRST_RELEASE} or newer.`, + message: `supabase-swift ${version ?? revision} has no ${BINARY}; update to ${FIRST_RELEASE} or newer.`, }); } // --disable-automatic-resolution builds the committed Package.resolved exactly, or fails. @@ -370,7 +779,7 @@ async function build({ if (reported !== version) { throw new TypegenError({ language: "swift", - message: `The \`${BINARY}\` built from supabase-swift ${revision} reports version ${reported}, but Package.resolved pins ${version}.`, + message: `The \`${BINARY}\` built from supabase-swift ${revision} reports version ${printable(reported)}, not ${version}.`, }); } } @@ -422,13 +831,14 @@ function buildEnvironment( }; } +const absolute = (path: string | undefined) => + path && isAbsolute(path) ? path : undefined; + /** Relative or empty values are ignored, as the XDG base directory spec asks. */ function cacheDirectory( env: Readonly>, platform: string, ): string { - const absolute = (path: string | undefined) => - path && isAbsolute(path) ? path : undefined; const base = absolute(env.XDG_CACHE_HOME) ?? join( @@ -438,32 +848,102 @@ function cacheDirectory( return join(base, "supabase", "typegen"); } -interface PinState { +/** A value for the terminal: quoted, with control characters escaped. */ +const printable = (value: string): string => + JSON.stringify(value).replace( + /[\u007f-\u009f]/g, + (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + +interface Pin { + readonly revision: string; readonly version?: string; readonly branch?: string; - readonly revision?: string; + /** The `Package.resolved` it came from. */ + readonly file: string; } /** * The supabase-swift pin of the nearest `Package.resolved` that has one, - * looking in `cwd` and then each parent. Only regular files that `userId` - * owns count. + * looking in `cwd` and then each parent. Without one there, a monorepo's + * apps may sit below `cwd`, so it looks up to `SEARCH_DEPTH` levels down and + * accepts what it finds only when every pin there agrees on the revision. + * Only regular files that `userId` owns count. */ -async function pinnedState( +async function findPin( cwd: string, userId: number | undefined, -): Promise { +): Promise { for (let directory = cwd; ; directory = dirname(directory)) { - for (const file of await resolvedFiles(directory, userId)) { - const state = stateIn(await readFile(file, "utf8")); - if (state) { - return state; - } + const [pin] = await pinsIn(directory, userId); + if (pin) { + return pin; } if (dirname(directory) === directory) { - return undefined; + break; } } + const pins: Pin[] = []; + for (const directory of await foldersBelow(cwd)) { + pins.push(...(await pinsIn(directory, userId))); + } + const revisions = new Set(pins.map((pin) => pin.revision)); + if (revisions.size > 1) { + throw new TypegenError({ + language: "swift", + message: `No supabase-swift pin found in \`${cwd}\` or its parents, and the Package.resolved files below it pin different supabase-swift revisions:\n${pins + .map( + (pin) => ` ${relative(cwd, pin.file)}: ${printable(pin.revision)}`, + ) + .join( + "\n", + )}\nRun from your app directory, or pass --${SWIFT_TYPEGEN_SOURCE} to choose the source.`, + }); + } + return pins[0]; +} + +/** Folders up to `SEARCH_DEPTH` levels below `cwd`, in a stable order, without following links. */ +async function foldersBelow(cwd: string): Promise { + const found: string[] = []; + let level = [cwd]; + for (let depth = 0; depth < SEARCH_DEPTH; depth++) { + const next: string[] = []; + for (const directory of level) { + const entries = await readdir(directory, { withFileTypes: true }).catch( + () => [], + ); + for (const entry of entries) { + // Xcode's copies inside these bundles are read with their parent folder. + if ( + entry.isDirectory() && + !SKIPPED_FOLDERS.has(entry.name) && + !/\.(xcodeproj|xcworkspace)$/.test(entry.name) + ) { + next.push(join(directory, entry.name)); + } + } + } + next.sort(); + found.push(...next); + level = next; + } + return found; +} + +async function pinsIn( + directory: string, + userId: number | undefined, +): Promise { + const pins: Pin[] = []; + for (const file of await resolvedFiles(directory, userId)) { + const state = stateIn(await readFile(file, "utf8")); + if (state) { + pins.push({ ...state, file }); + } + } + return pins; } async function resolvedFiles( @@ -511,9 +991,10 @@ const text = (value: unknown): string | undefined => /** * Reads every `Package.resolved` format (version 1 nests the pins under - * `object`). Anything that is not the expected shape counts as no pin. + * `object`). Anything that is not the expected shape, including a + * supabase-swift pin without a revision, counts as no pin. */ -function stateIn(contents: string): PinState | undefined { +function stateIn(contents: string): Omit | undefined { let parsed: unknown; try { parsed = JSON.parse(contents); @@ -530,11 +1011,14 @@ function stateIn(contents: string): PinState | undefined { const location = text(pin?.location) ?? text(pin?.repositoryURL) ?? ""; if (REPOSITORY.test(location)) { const state = record(pin?.state); - return { - version: text(state?.version), - branch: text(state?.branch), - revision: text(state?.revision), - }; + const revision = text(state?.revision); + return revision === undefined + ? undefined + : { + revision, + version: text(state?.version), + branch: text(state?.branch), + }; } } return undefined; diff --git a/packages/typegen/test/languages.test.ts b/packages/typegen/test/languages.test.ts index c5156de..d36cbbd 100644 --- a/packages/typegen/test/languages.test.ts +++ b/packages/typegen/test/languages.test.ts @@ -46,6 +46,12 @@ describe("languages", () => { default: "internal", help: "Access control for Swift generated types.", }, + { + name: "swift-typegen-source", + audience: "user", + kind: "string", + help: "Where to build supabase-typegen from: a supabase-swift version (3.0.0-alpha.1), a branch (main), a full commit SHA, or a local checkout (./supabase-swift). Default: the supabase-swift pin in the nearest Package.resolved, else the latest 3.x release.", + }, ]); expect(findLanguage("go")?.options).toEqual([]); expect(findLanguage("python")?.options).toEqual([]); diff --git a/packages/typegen/test/swift.test.ts b/packages/typegen/test/swift.test.ts index 948e44b..6227688 100644 --- a/packages/typegen/test/swift.test.ts +++ b/packages/typegen/test/swift.test.ts @@ -26,20 +26,67 @@ import { ToolNotInstalledError, TypegenError, } from "../src/index.ts"; -import { createSwift } from "../src/languages/swift.ts"; +import { + compareVersions, + createSwift, + latestRelease, + parseRefs, + parseTypegenSource, +} from "../src/languages/swift.ts"; import { commandNotFound, createFakeHost, rejection } from "./helpers.ts"; import { unsortedMetadata } from "./fixtures.ts"; const success = (stdout: string) => ({ exitCode: 0, stdout, stderr: "" }); +const LIST_TAGS = + "git ls-remote --tags https://github.com/supabase/supabase-swift"; + +const REVISION = "0123456789abcdef0123456789abcdef01234567"; +const OTHER = "fedcba9876543210fedcba9876543210fedcba98"; describe("swift", () => { + let cache: string; + + beforeEach(() => { + cache = realpathSync(mkdtempSync(join(tmpdir(), "typegen-swift-cache-"))); + mkdirSync(dirname(binary()), { recursive: true }); + writeFileSync(binary(), ""); + }); + + afterEach(() => { + rmSync(cache, { recursive: true, force: true }); + }); + + /** The binary a commit-SHA source finds already built, so nothing else is spawned. */ + const binary = () => + join(cache, "supabase/typegen", REVISION, "supabase-typegen"); + + const generate = ( + respond: Parameters[0], + values: Record = {}, + ) => { + const host = createFakeHost(respond, { + env: { PATH: "/usr/bin", HOME: "/home/dev", XDG_CACHE_HOME: cache }, + }); + const output = createSwift({ + platform: "darwin", + temporaryDirectory: () => cache, + log: () => {}, + userId: () => undefined, + }).generate( + unsortedMetadata, + { "swift-typegen-source": REVISION, ...values }, + host, + ); + return { host, output }; + }; + test("runs supabase-typegen in the project with the sorted document on stdin", async () => { - const host = createFakeHost(() => success("struct Tickets {}\n")); - const output = await swift.generate(unsortedMetadata, {}, host); + const { host, output } = generate(() => success("struct Tickets {}\n")); - expect(output).toBe("struct Tickets {}\n"); + expect(await output).toBe("struct Tickets {}\n"); + expect(host.requests).toHaveLength(1); const request = host.requests[0]!; - expect(request.command).toBe("supabase-typegen"); + expect(request.command).toBe(binary()); expect(request.args).toEqual(["--access-control", "internal"]); expect(request.cwd).toBe(host.cwd); expect(request.stdin).toBe( @@ -48,12 +95,10 @@ describe("swift", () => { }); test("maps swift-access-control to --access-control", async () => { - const host = createFakeHost(() => success("")); - await swift.generate( - unsortedMetadata, - { "swift-access-control": "public" }, - host, - ); + const { host, output } = generate(() => success(""), { + "swift-access-control": "public", + }); + await output; expect(host.requests[0]!.args).toEqual(["--access-control", "public"]); }); @@ -73,24 +118,26 @@ describe("swift", () => { } }); - test("reports a missing supabase-typegen with an install hint", async () => { - const host = createFakeHost(() => { - throw commandNotFound("supabase-typegen"); + test("reports a missing supabase-typegen with a hint that names swift-typegen-source", async () => { + const { output } = generate(() => { + throw commandNotFound(binary()); }); - const error = await rejection(swift.generate(unsortedMetadata, {}, host)); + const error = await rejection(output); expect(error).toBeInstanceOf(ToolNotInstalledError); const notInstalled = error as ToolNotInstalledError; - expect(notInstalled.tool).toBe("supabase-typegen"); - expect(notInstalled.installHint).toContain("SUPABASE_TYPEGEN"); + expect(notInstalled.tool).toBe(binary()); + expect(notInstalled.installHint).toContain("--swift-typegen-source"); + expect(notInstalled.installHint).not.toContain("SUPABASE_TYPEGEN"); + expect(notInstalled.installHint).not.toContain("PATH"); }); test("reports exit 65 as a rejected document", async () => { - const host = createFakeHost(() => ({ + const { output } = generate(() => ({ exitCode: 65, stdout: "", stderr: "error: unsupported GeneratorMetadata version '7'.\n", })); - const error = await rejection(swift.generate(unsortedMetadata, {}, host)); + const error = await rejection(output); expect(error).toBeInstanceOf(MetadataRejectedError); expect((error as MetadataRejectedError).stderr).toBe( "error: unsupported GeneratorMetadata version '7'.", @@ -98,31 +145,46 @@ describe("swift", () => { }); test("reports any other failure with the exit code and stderr", async () => { - const host = createFakeHost(() => ({ + const { output } = generate(() => ({ exitCode: 1, stdout: "", stderr: "error: the primary key column 'todos.id' is nullable.\n", })); - const error = await rejection(swift.generate(unsortedMetadata, {}, host)); + const error = await rejection(output); expect(error).toBeInstanceOf(ToolFailedError); expect(error).not.toBeInstanceOf(MetadataRejectedError); expect((error as ToolFailedError).message).toBe( - "`supabase-typegen --access-control internal` exited with code 1.\nerror: the primary key column 'todos.id' is nullable.", + `\`${binary()} --access-control internal\` exited with code 1.\nerror: the primary key column 'todos.id' is nullable.`, ); }); -}); -const REVISION = "0123456789abcdef0123456789abcdef01234567"; + test("refuses Windows, where the source build is not supported", async () => { + const host = createFakeHost(() => success("")); + const error = await rejection( + createSwift({ + platform: "win32", + temporaryDirectory: () => cache, + log: () => {}, + userId: () => undefined, + }).generate(unsortedMetadata, {}, host), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain("Windows"); + expect(host.requests).toEqual([]); + }); +}); /** * Stand-ins for `git` and `swift`, put alone on PATH. Each appends its command - * line to `$FAKE_LOG`. `git` fakes a fetch of the revision it is given, and - * checking it out creates `tools/supabase-typegen` unless `FAKE_NO_TYPEGEN` is - * set. `swift build` writes a `supabase-typegen` that prints - * `$FAKE_TYPEGEN_VERSION` for `--version` and `generated` otherwise. The - * `FAKE_*_FAIL` variables make a step fail the way the real tool does. Both - * fail if a git repository variable reaches them. They - * call coreutils by absolute path because PATH holds only the fakes. + * line to `$FAKE_LOG`. `git ls-remote` lists `$FAKE_HEADS` for a branch and + * `$FAKE_TAGS` otherwise (`\c` lists nothing), `git` fakes a fetch of the + * revision it is given, and checking it out creates `tools/supabase-typegen` + * unless `FAKE_NO_TYPEGEN` is set. `swift build` writes a `supabase-typegen` + * into its `--package-path` that prints `$FAKE_TYPEGEN_VERSION` for + * `--version` and otherwise the package's `marker` file, or `generated` + * without one. The `FAKE_*_FAIL` variables make a step fail the way the real + * tool does. Both fail if a git repository variable reaches them. They call + * coreutils by absolute path because PATH holds only the fakes. */ const FAKE_GIT = `#!/bin/sh echo "git $*" >> "$FAKE_LOG" @@ -133,8 +195,14 @@ fi case "$1" in --version) echo "git version 2.50.0" ;; ls-remote) - if [ -n "$FAKE_TAGS" ]; then printf '%b' "$FAKE_TAGS" - else printf '%s\\trefs/tags/v3.0.0-alpha.1\\n' ${REVISION}; fi ;; + case "$*" in + *refs/heads/*) + if [ -n "$FAKE_HEADS" ]; then printf '%b' "$FAKE_HEADS" + else printf '%s\\trefs/heads/main\\n' ${REVISION}; fi ;; + *) + if [ -n "$FAKE_TAGS" ]; then printf '%b' "$FAKE_TAGS" + else printf '%s\\trefs/tags/v3.0.0-alpha.1\\n' ${REVISION}; fi ;; + esac ;; clone) for argument; do directory=$argument; done /bin/mkdir "$directory" ;; @@ -172,9 +240,17 @@ case "$1" in echo "error: fatalError" >&2 exit 1 fi - /bin/mkdir -p tools/supabase-typegen/.build/release - binary=tools/supabase-typegen/.build/release/supabase-typegen - printf '#!/bin/sh\\necho "typegen $*" >> "$FAKE_LOG"\\nif [ "$1" = --version ]; then echo %s; exit 0; fi\\n/bin/cat > /dev/null\\necho generated\\n' "\${FAKE_TYPEGEN_VERSION:-3.0.0-alpha.1}" > "$binary" + package=tools/supabase-typegen + previous= + for argument; do + if [ "$previous" = --package-path ]; then package=$argument; fi + previous=$argument + done + output=generated + if [ -f "$package/marker" ]; then output=$(/bin/cat "$package/marker"); fi + /bin/mkdir -p "$package/.build/release" + binary="$package/.build/release/supabase-typegen" + printf '#!/bin/sh\\necho "typegen $*" >> "$FAKE_LOG"\\nif [ "$1" = --version ]; then echo %s; exit 0; fi\\n/bin/cat > /dev/null\\necho %s\\n' "\${FAKE_TYPEGEN_VERSION:-3.0.0-alpha.1}" "$output" > "$binary" /bin/chmod 755 "$binary" ;; *) echo "unexpected swift $*" >&2; exit 2 ;; esac @@ -246,10 +322,11 @@ describe("swift source build", () => { cwd: string, env: Record = {}, platform = "darwin", + values: Record = {}, ) => make(platform).generate( unsortedMetadata, - {}, + values, createNodeHost({ cwd, env: { @@ -264,8 +341,14 @@ describe("swift source build", () => { const ran = (prefix: string) => commands().some((line) => line.startsWith(prefix)); const commands = () => readFileSync(logFile, "utf8").split("\n").slice(0, -1); - const cached = (cache = "home/Library/Caches") => - join(root, cache, "supabase/typegen", REVISION, "supabase-typegen"); + /** Runs with `swift-typegen-source` set to `source`. */ + const runFrom = ( + cwd: string, + source: string, + env: Record = {}, + ) => run(cwd, env, "darwin", { "swift-typegen-source": source }); + const cached = (cache = "home/Library/Caches", revision = REVISION) => + join(root, cache, "supabase/typegen", revision, "supabase-typegen"); const leftovers = () => [ ...readdirSync(scratch), ...(existsSync(dirname(cached())) ? readdirSync(dirname(cached())) : []), @@ -291,11 +374,14 @@ describe("swift source build", () => { expect(statSync(cached()).mode & 0o777).toBe(0o755); expect(readdirSync(dirname(cached()))).toEqual(["supabase-typegen"]); expect(readdirSync(scratch)).toEqual([]); - expect(messages).toHaveLength(1); - expect(messages[0]).toContain( + expect(messages).toHaveLength(2); + expect(messages[0]).toBe( + "Using version `3.0.0-alpha.1` at `0123456…`, pinned in Package.resolved.", + ); + expect(messages[1]).toContain( "Building supabase-typegen 3.0.0-alpha.1 from source", ); - expect(messages[0]).toContain("a few minutes"); + expect(messages[1]).toContain("a few minutes"); }); test("runs the cached binary without git or swift", async () => { @@ -307,7 +393,9 @@ describe("swift source build", () => { expect(await run(cwd)).toBe("generated\n"); expect(commands()).toEqual(["typegen --access-control internal"]); - expect(messages).toEqual([]); + expect(messages).toEqual([ + "Using version `3.0.0-alpha.1` at `0123456…`, pinned in Package.resolved.", + ]); }); test("builds a branch pin once its revision is on that upstream branch", async () => { @@ -393,29 +481,40 @@ describe("swift source build", () => { test("skips a Package.resolved owned by another user", async () => { const cwd = project({ "Package.resolved": versionPin() }); - const error = await rejection( - make("darwin", (process.getuid?.() ?? 0) + 1).generate( + expect( + await make("darwin", (process.getuid?.() ?? 0) + 1).generate( unsortedMetadata, {}, - createNodeHost({ cwd, env: { PATH: bin, FAKE_LOG: logFile } }), + createNodeHost({ + cwd, + env: { PATH: bin, HOME: join(root, "home"), FAKE_LOG: logFile }, + }), ), - ); - expect(error).toBeInstanceOf(ToolNotInstalledError); - expect((error as ToolNotInstalledError).tool).toBe("supabase-typegen"); - expect(ran("git")).toBe(false); + ).toBe("generated\n"); + expect(commands()[0]).toBe(LIST_TAGS); + expect(messages[0]).toStartWith("No supabase-swift pin found"); }); test("skips malformed and non-regular Package.resolved files", async () => { - for (const contents of ["null", '{"pins":{}}', '{"pins":[null]}', "[1]"]) { + const malformed = [ + "null", + '{"pins":{}}', + '{"pins":[null]}', + "[1]", + pin({ version: "3.0.0-alpha.1" }), + ]; + for (const contents of malformed) { + writeFileSync(logFile, ""); const cwd = project({ "Package.resolved": contents }); - const error = await rejection(run(cwd)); - expect(error).toBeInstanceOf(ToolNotInstalledError); + expect(await run(cwd)).toBe("generated\n"); + expect(commands()[0]).toBe(LIST_TAGS); } + writeFileSync(logFile, ""); const cwd = project({}); rmSync(join(cwd, "Package.resolved"), { force: true }); execFileSync("/usr/bin/mkfifo", [join(cwd, "Package.resolved")]); - expect(await rejection(run(cwd))).toBeInstanceOf(ToolNotInstalledError); - expect(commands()).toEqual([]); + expect(await run(cwd)).toBe("generated\n"); + expect(commands()[0]).toBe(LIST_TAGS); }); test("reads an Xcode project's Package.resolved in the version 1 format", async () => { @@ -493,37 +592,19 @@ describe("swift source build", () => { expect(readdirSync(scratch)).toEqual([]); }); - test("falls back to PATH without a supabase-swift pin", async () => { + test("reads neither SUPABASE_TYPEGEN nor a supabase-typegen on PATH", async () => { const cwd = project({}); + const stray = join(bin, "supabase-typegen"); writeFileSync( - join(bin, "supabase-typegen"), - '#!/bin/sh\necho "typegen $*" >> "$FAKE_LOG"\necho from-path\n', - { mode: 0o755 }, + stray, + '#!/bin/sh\necho "stray $*" >> "$FAKE_LOG"\necho stray\n', + { + mode: 0o755, + }, ); - expect(await run(cwd)).toBe("from-path\n"); - expect(commands()).toEqual(["typegen --access-control internal"]); - }); - - test("falls back to PATH on Windows", async () => { - const cwd = project({ "Package.resolved": versionPin() }); - const language = make("win32"); - const host = createFakeHost(() => success(""), { cwd }); - await language.generate(unsortedMetadata, {}, host); - expect(host.requests.map((request) => request.command)).toEqual([ - "supabase-typegen", - ]); - }); - - test("SUPABASE_TYPEGEN overrides the source build", async () => { - const cwd = project({ "Package.resolved": versionPin() }); - const host = createFakeHost(() => success(""), { - cwd, - env: { SUPABASE_TYPEGEN: "/opt/dev/supabase-typegen" }, - }); - await make().generate(unsortedMetadata, {}, host); - expect(host.requests.map((request) => request.command)).toEqual([ - "/opt/dev/supabase-typegen", - ]); + expect(await run(cwd, { SUPABASE_TYPEGEN: stray })).toBe("generated\n"); + expect(ran("stray")).toBe(false); + expect(existsSync(cached())).toBe(true); }); test("refuses a revision that is not a full commit SHA without running git", async () => { @@ -624,4 +705,542 @@ describe("swift source build", () => { expect(error).toBeInstanceOf(InvalidOptionError); expect(commands()).toEqual([]); }); + + describe("swift-typegen-source", () => { + const tagLookup = (tag: string) => + `git ls-remote https://github.com/supabase/supabase-swift refs/tags/v${tag} refs/tags/v${tag}^{} refs/tags/${tag} refs/tags/${tag}^{}`; + const checkoutAndBuild = [ + "git init", + `git fetch --depth 1 https://github.com/supabase/supabase-swift ${REVISION}`, + "git checkout --detach FETCH_HEAD", + "git rev-parse HEAD", + "swift build -c release --package-path tools/supabase-typegen --disable-automatic-resolution", + ]; + + for (const value of ["3.0.0-alpha.1", "v3.0.0-alpha.1"]) { + test(`builds the tagged commit of version ${value} over the lockfile pin`, async () => { + const cwd = project({ + "Package.resolved": pin({ + version: "3.0.0-alpha.2", + revision: OTHER, + }), + }); + + expect(await runFrom(cwd, value)).toBe("generated\n"); + + expect(commands()).toEqual([ + tagLookup("3.0.0-alpha.1"), + "git --version", + "swift --version", + ...checkoutAndBuild, + "typegen --version", + "typegen --access-control internal", + ]); + expect(messages[0]).toBe( + "Using version `3.0.0-alpha.1` at `0123456…`.", + ); + expect(existsSync(cached())).toBe(true); + expect(existsSync(cached(undefined, OTHER))).toBe(false); + }); + } + + test("builds the commit an annotated version tag points at", async () => { + const cwd = project({}); + expect( + await runFrom(cwd, "3.0.0-alpha.1", { + FAKE_TAGS: `${"a".repeat(40)}\\trefs/tags/v3.0.0-alpha.1\\n${REVISION}\\trefs/tags/v3.0.0-alpha.1^{}\\n`, + }), + ).toBe("generated\n"); + expect(existsSync(cached())).toBe(true); + }); + + test("resolves a branch on every run and reuses the cache while it stays put", async () => { + const cwd = project({}); + + expect(await runFrom(cwd, "main")).toBe("generated\n"); + expect(commands()).toEqual([ + "git ls-remote https://github.com/supabase/supabase-swift refs/heads/main", + "git --version", + "swift --version", + ...checkoutAndBuild, + "typegen --access-control internal", + ]); + expect(messages[0]).toBe("Using branch `main` at `0123456…`."); + + writeFileSync(logFile, ""); + expect(await runFrom(cwd, "main")).toBe("generated\n"); + expect(commands()).toEqual([ + "git ls-remote https://github.com/supabase/supabase-swift refs/heads/main", + "typegen --access-control internal", + ]); + + writeFileSync(logFile, ""); + messages = []; + expect( + await runFrom(cwd, "main", { + FAKE_HEADS: `${OTHER}\\trefs/heads/main\\n`, + }), + ).toBe("generated\n"); + expect(ran("swift build")).toBe(true); + expect(messages[0]).toBe("Using branch `main` at `fedcba9…`."); + expect(existsSync(cached(undefined, OTHER))).toBe(true); + }); + + test("matches the branch exactly in the ls-remote listing", async () => { + const cwd = project({}); + const error = await rejection( + runFrom(cwd, "main", { + FAKE_HEADS: `${REVISION}\\trefs/heads/feature/main\\n`, + }), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toBe( + "No branch named `main` in supabase/supabase-swift.", + ); + }); + + test("builds a full commit SHA once it is on the default branch", async () => { + const cwd = project({}); + + expect(await runFrom(cwd, REVISION.toUpperCase())).toBe("generated\n"); + + expect(commands()).toEqual([ + "git --version", + "swift --version", + "git clone --bare --filter=tree:0 --no-tags --single-branch https://github.com/supabase/supabase-swift history.git", + `git merge-base --is-ancestor ${REVISION} HEAD`, + ...checkoutAndBuild, + "typegen --access-control internal", + ]); + expect(messages[0]).toBe("Using commit `0123456…`."); + expect(existsSync(cached())).toBe(true); + }); + + test("refuses a full commit SHA that is not upstream's", async () => { + const cwd = project({}); + const error = await rejection( + runFrom(cwd, REVISION, { FAKE_NOT_ON_BRANCH: "1" }), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain( + `${REVISION} is not on supabase-swift's default branch`, + ); + expect((error as TypegenError).message).toContain( + "--swift-typegen-source", + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); + + test("builds a local checkout in place, with its uncommitted changes, and never caches it", async () => { + const cwd = project({ "Package.resolved": versionPin() }); + const checkout = join(root, "supabase-swift"); + const packagePath = join(checkout, "tools/supabase-typegen"); + mkdirSync(packagePath, { recursive: true }); + writeFileSync(join(packagePath, "Package.swift"), ""); + writeFileSync(join(packagePath, "marker"), "first"); + + expect(await runFrom(cwd, "../supabase-swift")).toBe("first\n"); + expect(commands()).toEqual([ + `swift build -c release --package-path ${packagePath}`, + "typegen --access-control internal", + ]); + expect(messages[0]).toContain(`Using the local checkout ${checkout}`); + expect( + existsSync(join(packagePath, ".build/release/supabase-typegen")), + ).toBe(true); + + writeFileSync(join(packagePath, "marker"), "second"); + writeFileSync(logFile, ""); + expect(await runFrom(cwd, "../supabase-swift")).toBe("second\n"); + expect(ran("swift build")).toBe(true); + expect(existsSync(join(root, "home/Library/Caches"))).toBe(false); + expect(readdirSync(scratch)).toEqual([]); + }); + + test("expands ~ in a local checkout path", async () => { + const cwd = project({}); + const packagePath = join( + root, + "home/src/supabase-swift/tools/supabase-typegen", + ); + mkdirSync(packagePath, { recursive: true }); + writeFileSync(join(packagePath, "Package.swift"), ""); + expect(await runFrom(cwd, "~/src/supabase-swift")).toBe("generated\n"); + expect(commands()[0]).toBe( + `swift build -c release --package-path ${packagePath}`, + ); + }); + + test("refuses a local path without tools/supabase-typegen", async () => { + const cwd = project({}); + mkdirSync(join(cwd, "old-checkout")); + for (const value of ["./old-checkout", "./missing"]) { + const error = await rejection(runFrom(cwd, value)); + expect(error).toBeInstanceOf(InvalidOptionError); + expect((error as InvalidOptionError).message).toContain( + "has no tools/supabase-typegen", + ); + } + expect(commands()).toEqual([]); + }); + + test("refuses an abbreviated commit SHA without running git", async () => { + const cwd = project({}); + const error = await rejection(runFrom(cwd, REVISION.slice(0, 7))); + expect(error).toBeInstanceOf(InvalidOptionError); + expect((error as InvalidOptionError).option).toBe("swift-typegen-source"); + expect((error as InvalidOptionError).message).toContain( + "full 40-character SHA", + ); + expect(commands()).toEqual([]); + }); + + test("points a relative path without ./ at the ./ form", async () => { + const cwd = project({}); + mkdirSync(join(cwd, "supabase-swift")); + const error = await rejection( + runFrom(cwd, "supabase-swift", { FAKE_HEADS: "\\c" }), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toBe( + `No branch named \`supabase-swift\` in supabase/supabase-swift. To build the local checkout at ${join(cwd, "supabase-swift")}, pass ./supabase-swift.`, + ); + expect(ran("git fetch")).toBe(false); + }); + + test("names the tag or branch a failed lookup tried", async () => { + const cwd = project({}); + const tag = await rejection( + runFrom(cwd, "v3.0.0-alpa.1", { FAKE_TAGS: "\\c" }), + ); + expect(tag).toBeInstanceOf(TypegenError); + expect((tag as TypegenError).message).toStartWith( + "No tag named `v3.0.0-alpa.1` in supabase/supabase-swift", + ); + const branch = await rejection( + runFrom(cwd, "release/v9", { FAKE_HEADS: "\\c" }), + ); + expect((branch as TypegenError).message).toBe( + "No branch named `release/v9` in supabase/supabase-swift.", + ); + expect(ran("git fetch")).toBe(false); + expect(leftovers()).toEqual([]); + }); + + test("refuses a 2.x version without running git", async () => { + const cwd = project({}); + const error = await rejection(runFrom(cwd, "2.56.0")); + expect(error).toBeInstanceOf(InvalidOptionError); + expect((error as InvalidOptionError).message).toContain( + "supabase-swift 2.56.0 has no supabase-typegen; pass 3.0.0-alpha.1 or newer", + ); + expect(commands()).toEqual([]); + }); + + test("refuses a branch name git could read as an option or a refspec", async () => { + const cwd = project({}); + for (const value of [ + "--upload-pack=touch", + "-b", + "has space", + "a..b", + "main:refs/heads/x", + "main\u001b]0;x\u0007", + "", + ]) { + const error = await rejection(runFrom(cwd, value)); + expect(error).toBeInstanceOf(InvalidOptionError); + expect((error as InvalidOptionError).message).not.toContain("\u001b"); + } + expect(commands()).toEqual([]); + }); + }); + + describe("lockfile walk", () => { + test("skips a Package.resolved without a supabase-swift pin", async () => { + const app = project({ + "Package.resolved": versionPin(), + "ios/Package.resolved": JSON.stringify({ + pins: [ + { + identity: "swift-crypto", + location: "https://github.com/apple/swift-crypto.git", + state: { version: "3.0.0", revision: OTHER }, + }, + ], + version: 2, + }), + }); + expect(await run(join(app, "ios"))).toBe("generated\n"); + expect(messages[0]).toBe( + "Using version `3.0.0-alpha.1` at `0123456…`, pinned in ../Package.resolved.", + ); + }); + + test("reads an Xcode workspace's Package.resolved", async () => { + const cwd = project({ + "App.xcworkspace/xcshareddata/swiftpm/Package.resolved": versionPin(), + }); + await run(cwd); + expect(existsSync(cached())).toBe(true); + }); + + test("searches two levels down when no parent has a pin, skipping dependency folders", async () => { + const cwd = project({ + "apps/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved": + versionPin(), + "apps/macos/Package.resolved": versionPin(), + "apps/ios/deeper/Package.resolved": pin({ revision: OTHER }), + "node_modules/Package.resolved": pin({ revision: OTHER }), + ".build/checkouts/Package.resolved": pin({ revision: OTHER }), + "Pods/Package.resolved": pin({ revision: OTHER }), + "DerivedData/Package.resolved": pin({ revision: OTHER }), + ".git/Package.resolved": pin({ revision: OTHER }), + }); + expect(await run(cwd)).toBe("generated\n"); + expect(messages[0]).toBe( + "Using version `3.0.0-alpha.1` at `0123456…`, pinned in apps/ios/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved.", + ); + expect(existsSync(cached(undefined, OTHER))).toBe(false); + }); + + test("asks for swift-typegen-source when the lockfiles below disagree", async () => { + const cwd = project({ + "ios/Package.resolved": versionPin(), + "server/Package.resolved": pin({ branch: "main", revision: OTHER }), + }); + const error = await rejection(run(cwd)); + expect(error).toBeInstanceOf(TypegenError); + const { message } = error as TypegenError; + expect(message).toContain("pin different supabase-swift revisions"); + expect(message).toContain(`ios/Package.resolved: "${REVISION}"`); + expect(message).toContain(`server/Package.resolved: "${OTHER}"`); + expect(message).toContain("--swift-typegen-source"); + expect(commands()).toEqual([]); + }); + + test("prefers a parent's pin over the lockfiles below", async () => { + const cwd = project({ + "Package.resolved": versionPin(), + "web/ios/Package.resolved": pin({ revision: OTHER }), + "web/server/Package.resolved": pin({ revision: "e".repeat(40) }), + }); + expect(await run(cwd)).toBe("generated\n"); + expect(existsSync(cached())).toBe(true); + }); + }); + + describe("latest release", () => { + test("builds the highest 3.x prerelease and says why", async () => { + const cwd = project({}); + const annotated = "a".repeat(40); + const tags = [ + `${"1".repeat(40)}\trefs/tags/v2.40.0`, + `${"2".repeat(40)}\trefs/tags/v3.0.0-alpha.9`, + `${annotated}\trefs/tags/v3.0.0-alpha.10`, + `${OTHER}\trefs/tags/v3.0.0-alpha.10^{}`, + `${"4".repeat(40)}\trefs/tags/v4.0.0-alpha.1`, + ]; + + expect( + await run(cwd, { + FAKE_TAGS: tags.join("\\n").replaceAll("\t", "\\t"), + FAKE_TYPEGEN_VERSION: "3.0.0-alpha.10", + }), + ).toBe("generated\n"); + + expect(commands()[0]).toBe(LIST_TAGS); + expect(commands()).toContain( + `git fetch --depth 1 https://github.com/supabase/supabase-swift ${OTHER}`, + ); + expect(commands()).toContain("typegen --version"); + expect(ran("git clone")).toBe(false); + expect(messages[0]).toBe( + `No supabase-swift pin found from \`${cwd}\`. Using 3.0.0-alpha.10 (latest) at \`fedcba9…\`. Run from your app directory, or pass --swift-typegen-source, to match your pinned version.`, + ); + expect(existsSync(cached(undefined, OTHER))).toBe(true); + }); + + test("fails when supabase-swift has no 3.x release", async () => { + const cwd = project({}); + const error = await rejection( + run(cwd, { FAKE_TAGS: `${OTHER}\\trefs/tags/v2.40.0\\n` }), + ); + expect(error).toBeInstanceOf(TypegenError); + expect((error as TypegenError).message).toContain("no 3.x release"); + expect(commands()).toEqual([LIST_TAGS]); + }); + }); +}); + +describe("parseTypegenSource", () => { + const base = { cwd: "/work/app", home: "/home/dev" }; + const parse = (value: string) => parseTypegenSource(value, base); + const reason = (value: string) => { + try { + parse(value); + } catch (error) { + expect(error).toBeInstanceOf(InvalidOptionError); + return (error as InvalidOptionError).message; + } + throw new Error(`${value} was accepted`); + }; + + test("reads ./, ../, / and ~/ as local paths", () => { + expect(parse("./supabase-swift")).toEqual({ + kind: "path", + path: "/work/app/supabase-swift", + }); + expect(parse("../supabase-swift")).toEqual({ + kind: "path", + path: "/work/supabase-swift", + }); + expect(parse(".")).toEqual({ kind: "path", path: "/work/app" }); + expect(parse("/src/supabase-swift/")).toEqual({ + kind: "path", + path: "/src/supabase-swift", + }); + expect(parse("~/src/supabase-swift")).toEqual({ + kind: "path", + path: "/home/dev/src/supabase-swift", + }); + expect(parse("~")).toEqual({ kind: "path", path: "/home/dev" }); + expect(parse("./3.0.0")).toEqual({ kind: "path", path: "/work/app/3.0.0" }); + }); + + test("refuses other forms that start like a path", () => { + for (const value of [".hidden", "...", "~other/supabase-swift"]) { + expect(reason(value)).toContain("Start a relative path with ./"); + } + }); + + test("reads 40 hex characters as a commit, in lowercase", () => { + expect(parse(REVISION.toUpperCase())).toEqual({ + kind: "commit", + revision: REVISION, + }); + }); + + test("refuses an abbreviated commit SHA", () => { + for (const value of [REVISION.slice(0, 7), REVISION.slice(0, 39)]) { + expect(reason(value)).toContain("full 40-character SHA"); + } + }); + + test("reads a semantic version with or without v", () => { + for (const value of [ + "3.0.0", + "v3.0.0", + "3.0.0-alpha.1", + "v3.1.2+build.5", + ]) { + expect(parse(value)).toEqual({ + kind: "version", + version: value.replace(/^v/, ""), + }); + } + }); + + test("reads anything else that is a plain branch name as a branch", () => { + for (const value of ["main", "release/v3", "feat/typegen-1.2", "v3"]) { + expect(parse(value)).toEqual({ kind: "branch", branch: value }); + } + }); + + test("refuses a branch name git could read as an option or a refspec", () => { + for (const value of [ + "-b", + "--upload-pack=touch", + "a b", + "a\tb", + "a..b", + "main:x", + "main^{}", + "x\u0000y", + "x\u009by", + "", + ]) { + const message = reason(value); + expect(message).toContain("not a version"); + const control = Array.from({ length: message.length }, (_unused, index) => + message.charCodeAt(index), + ).filter((code) => code < 0x20 || (code >= 0x7f && code <= 0x9f)); + expect(control).toEqual([]); + } + }); +}); + +describe("compareVersions", () => { + test("orders by semantic version precedence", () => { + const ordered = [ + "2.99.99", + "3.0.0-alpha", + "3.0.0-alpha.1", + "3.0.0-alpha.2", + "3.0.0-alpha.9", + "3.0.0-alpha.10", + "3.0.0-alpha.beta", + "3.0.0-beta", + "3.0.0-beta.2", + "3.0.0-beta.11", + "3.0.0-rc.1", + "3.0.0", + "3.0.1", + "3.1.0", + "3.10.0", + ]; + const shuffled = [...ordered].reverse(); + expect(shuffled.sort(compareVersions)).toEqual(ordered); + expect(compareVersions("3.0.0+build.1", "3.0.0")).toBe(0); + }); +}); + +describe("latestRelease", () => { + test("takes the highest prerelease while there is no stable release", () => { + expect( + latestRelease( + ["v3.0.0-alpha.9", "v3.0.0-alpha.10", "v3.0.0-alpha.2", "v2.40.0"], + 3, + ), + ).toEqual({ tag: "v3.0.0-alpha.10", version: "3.0.0-alpha.10" }); + }); + + test("prefers the highest stable release once one exists", () => { + expect( + latestRelease(["v3.0.0", "v3.2.0-beta.1", "v3.1.4", "v3.1.10"], 3), + ).toEqual({ tag: "v3.1.10", version: "3.1.10" }); + }); + + test("never crosses the major version", () => { + expect( + latestRelease(["v2.40.0", "v4.0.0", "v4.0.0-alpha.1"], 3), + ).toBeUndefined(); + expect(latestRelease(["v3.0.0-alpha.1", "v4.0.0"], 3)).toEqual({ + tag: "v3.0.0-alpha.1", + version: "3.0.0-alpha.1", + }); + }); + + test("reads tags without v and skips what is not a version", () => { + expect( + latestRelease(["3.0.0", "latest", "v3", "release-3.5.0"], 3), + ).toEqual({ tag: "3.0.0", version: "3.0.0" }); + }); +}); + +describe("parseRefs", () => { + test("maps refs to commits, preferring an annotated tag's commit", () => { + const listing = [ + `${"a".repeat(40)}\trefs/tags/v3.0.0`, + `${REVISION}\trefs/tags/v3.0.0^{}`, + `${OTHER}\trefs/heads/main`, + "not-a-sha\trefs/heads/broken", + "", + ].join("\n"); + expect([...parseRefs(listing)]).toEqual([ + ["refs/tags/v3.0.0", REVISION], + ["refs/heads/main", OTHER], + ]); + }); });