diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index 4f1508de43..dd639f8829 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -6,7 +6,7 @@ }, "ignorePatterns": [ "**", - "!packages/api/src/**", + "!packages/api/**", "!packages/config/**", "!packages/stack/**", "!apps/cli/src/**", diff --git a/packages/api/scripts/dependent-types-check-cache.unit.test.ts b/packages/api/scripts/dependent-types-check-cache.unit.test.ts index f5d0b03a8e..faafa8f7fb 100644 --- a/packages/api/scripts/dependent-types-check-cache.unit.test.ts +++ b/packages/api/scripts/dependent-types-check-cache.unit.test.ts @@ -1,41 +1,53 @@ -import { execFileSync } from "node:child_process"; -import { closeSync, mkdtempSync, openSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { fileURLToPath } from "node:url"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, Schema, Stream } from "effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import { describe, expect, test } from "vitest"; // The scheduled API sync only rewrites this package, and the project-config mirror lives in // @supabase/config, so apps/cli's type-level drift guards only run on a PR touching either if turbo // folds that package's files into the dependent's cached `types:check` hash via `transit`. -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const packageDir = path.join(scriptDir, ".."); -const repoDir = path.join(packageDir, "..", ".."); -const turboBin = path.join(repoDir, "node_modules", ".bin", "turbo"); +const repoDir = fileURLToPath(new URL("../../..", import.meta.url)); +const turboBin = fileURLToPath(new URL("../../../node_modules/.bin/turbo", import.meta.url)); type DryRunTask = { readonly taskId: string; readonly dependencies: ReadonlyArray }; +const DryRunJson = Schema.fromJsonString( + Schema.Struct({ + tasks: Schema.Array( + Schema.Struct({ taskId: Schema.String, dependencies: Schema.Array(Schema.String) }), + ), + }), +); -function dryRunTypesCheckForDependents(packageName: string): ReadonlyArray { - // Redirects stdout to a file: Bun on Linux truncates a child's piped stdout at ~219 KB, and - // the dry-run JSON lists every hashed input file. - const tempDir = mkdtempSync(path.join(tmpdir(), "turbo-dry-run-")); - try { - const outputPath = path.join(tempDir, "dry-run.json"); - const fd = openSync(outputPath, "w"); - try { - execFileSync(turboBin, ["run", "types:check", `--filter=...${packageName}`, "--dry=json"], { - cwd: repoDir, - stdio: ["ignore", fd, "pipe"], - }); - } finally { - closeSync(fd); +const dryRunTypesCheckForDependents = (packageName: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make( + turboBin, + ["run", "types:check", `--filter=...${packageName}`, "--dry=json"], + { + cwd: repoDir, + stdin: "ignore", + }, + ), + ); + const [exitCode, stdout, stderr] = yield* Effect.all( + [ + handle.exitCode, + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) { + return yield* Effect.die(`turbo dry run exited with code ${exitCode}: ${stderr}`); } - return JSON.parse(readFileSync(outputPath, "utf8")).tasks; - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -} + const { tasks } = yield* Schema.decodeEffect(DryRunJson)(stdout); + return tasks; + }).pipe(Effect.scoped); function reachableTaskIds(tasks: ReadonlyArray, from: string): ReadonlySet { const byId = new Map(tasks.map((task) => [task.taskId, task])); @@ -53,19 +65,22 @@ function reachableTaskIds(tasks: ReadonlyArray, from: string): Reado describe("dependent types:check cache key", () => { test.each(["@supabase/api", "@supabase/config"])( "includes %s's files for every workspace that depends on it", - (packageName) => { - const tasks = dryRunTypesCheckForDependents(packageName); - const dependentTypesChecks = tasks - .map((task) => task.taskId) - .filter( - (taskId) => taskId.endsWith("#types:check") && !taskId.startsWith(`${packageName}#`), - ); + (packageName) => + Effect.runPromise( + Effect.gen(function* () { + const tasks = yield* dryRunTypesCheckForDependents(packageName); + const dependentTypesChecks = tasks + .map((task) => task.taskId) + .filter( + (taskId) => taskId.endsWith("#types:check") && !taskId.startsWith(`${packageName}#`), + ); - expect(dependentTypesChecks).toContain("supabase#types:check"); - for (const taskId of dependentTypesChecks) { - expect(reachableTaskIds(tasks, taskId), taskId).toContain(`${packageName}#transit`); - } - }, + expect(dependentTypesChecks).toContain("supabase#types:check"); + for (const taskId of dependentTypesChecks) { + expect(reachableTaskIds(tasks, taskId), taskId).toContain(`${packageName}#transit`); + } + }).pipe(Effect.provide(NodeServices.layer)), + ), 60_000, ); }); diff --git a/packages/api/scripts/download-openapi.ts b/packages/api/scripts/download-openapi.ts index 48edc093fd..2fdcc1a5ce 100644 --- a/packages/api/scripts/download-openapi.ts +++ b/packages/api/scripts/download-openapi.ts @@ -1,13 +1,14 @@ #!/usr/bin/env bun -import { readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; import { fileURLToPath } from "node:url"; +import { NodeServices } from "@effect/platform-node"; +import { Config, ConfigProvider, Console, Effect, FileSystem, Layer, Option, Schema } from "effect"; +import { FetchHttpClient, HttpClient } from "effect/http"; + const DEFAULT_SUPABASE_API_URL = "https://api.supabase.com"; -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const OPENAPI_SPEC_PATH = path.join(scriptDir, "../src/generated/openapi.json"); -const OPENAPI_OVERRIDES_PATH = path.join(scriptDir, "openapi-overrides.json"); -const OPENAPI_SOURCE_PATH = path.join(scriptDir, "openapi-source.json"); +const OPENAPI_SPEC_PATH = fileURLToPath(new URL("../src/generated/openapi.json", import.meta.url)); +const OPENAPI_OVERRIDES_PATH = fileURLToPath(new URL("openapi-overrides.json", import.meta.url)); +const OPENAPI_SOURCE_PATH = fileURLToPath(new URL("openapi-source.json", import.meta.url)); const OPENAPI_DOCUMENT_VERSIONS = ["v1", "v2"] as const; type OpenApiDocumentVersion = (typeof OPENAPI_DOCUMENT_VERSIONS)[number]; @@ -40,6 +41,25 @@ type JsonPatchOperation = ( readonly $comment?: unknown; }; +class OpenApiDownloadError extends Schema.TaggedError()( + "OpenApiDownloadError", + { message: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) {} + +const downloadStep = (step: () => A) => + Effect.try({ + try: step, + catch: (cause) => + new OpenApiDownloadError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); +const encodeJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const encodePrettyJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -267,9 +287,11 @@ export function parseOpenApiOverrides(parsed: unknown): ReadonlyArray> { - return parseOpenApiOverrides(JSON.parse(await readFile(OPENAPI_OVERRIDES_PATH, "utf8"))); -} +export const loadOpenApiOverrides = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const source = utf8.decode(yield* fs.readFile(OPENAPI_OVERRIDES_PATH)); + return yield* downloadStep(() => parseOpenApiOverrides(JSON.parse(source))); +}); function assertOpenApiSource(value: unknown): asserts value is OpenApiSource { if (!isRecord(value) || typeof value.baseUrl !== "string") { @@ -277,27 +299,30 @@ function assertOpenApiSource(value: unknown): asserts value is OpenApiSource { } } -async function loadPinnedBaseUrl(): Promise { - let raw: string; - try { - raw = await readFile(OPENAPI_SOURCE_PATH, "utf8"); - } catch (error) { +const loadPinnedBaseUrl = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const raw = yield* fs.readFile(OPENAPI_SOURCE_PATH).pipe( + Effect.map((bytes) => Option.some(utf8.decode(bytes))), // A missing pin falls through to the default base URL; only a present // but malformed pin is an error worth stopping for. - if (isRecord(error) && error.code === "ENOENT") { - return undefined; - } - throw error; - } - const parsed = JSON.parse(raw); - assertOpenApiSource(parsed); - return parsed.baseUrl; -} - -async function writeOpenApiSource(baseUrl: string): Promise { - const source: OpenApiSource = { baseUrl }; - await writeFile(OPENAPI_SOURCE_PATH, `${JSON.stringify(source, null, 2)}\n`); -} + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(Option.none())), + ); + if (Option.isNone(raw)) { + return undefined; + } + return yield* downloadStep(() => { + const parsed = JSON.parse(raw.value); + assertOpenApiSource(parsed); + return parsed.baseUrl; + }); +}); + +const writeOpenApiSource = (baseUrl: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const source: OpenApiSource = { baseUrl }; + yield* fs.writeFileString(OPENAPI_SOURCE_PATH, `${yield* encodePrettyJson(source)}\n`); + }); export function resolveOpenApiBaseUrl({ envBaseUrl, @@ -311,7 +336,7 @@ export function resolveOpenApiBaseUrl({ } export function resolveOpenApiSpecUrl( - baseUrl = process.env.SUPABASE_API_URL, + baseUrl: string | undefined, version: OpenApiDocumentVersion = "v1", ): string { const normalizedBaseUrl = resolveOpenApiBaseUrl({ envBaseUrl: baseUrl }); @@ -432,7 +457,7 @@ export function mergeOpenApiDocuments( }; } -export function assertMergedOpenApiDocument(document: OpenApiDocument): void { +export const assertMergedOpenApiDocument = Effect.fnUntraced(function* (document: OpenApiDocument) { const operationClaims = new Map>(); for (const [pathKey, pathValue] of Object.entries(document.paths)) { @@ -450,7 +475,9 @@ export function assertMergedOpenApiDocument(document: OpenApiDocument): void { if (typeof operationId !== "string" || operationId.length === 0) { // generate.ts silently skips operations without an operationId; the // documented escape hatch is adding a "remove" override for the path. - console.warn(`OpenAPI operation ${label} has no operationId; generate.ts will skip it.`); + yield* Console.warn( + `OpenAPI operation ${label} has no operationId; generate.ts will skip it.`, + ); continue; } @@ -466,9 +493,9 @@ export function assertMergedOpenApiDocument(document: OpenApiDocument): void { const prefix = versionPrefixMatch[1]!.toLowerCase(); const leadingSegment = pathKey.split("/")[1]?.toLowerCase(); if (leadingSegment !== prefix) { - throw new Error( - `OpenAPI operationId ${JSON.stringify(operationId)} for ${label} has version prefix ${JSON.stringify(prefix)} that does not match the path's leading segment ${JSON.stringify(leadingSegment ?? "")}.`, - ); + return yield* new OpenApiDownloadError({ + message: `OpenAPI operationId ${yield* encodeJson(operationId)} for ${label} has version prefix ${yield* encodeJson(prefix)} that does not match the path's leading segment ${yield* encodeJson(leadingSegment ?? "")}.`, + }); } } } @@ -476,49 +503,74 @@ export function assertMergedOpenApiDocument(document: OpenApiDocument): void { for (const [operationId, claims] of operationClaims) { if (claims.length > 1) { - throw new Error( - `Duplicate OpenAPI operationId ${JSON.stringify(operationId)} claimed by: ${claims.join(", ")}.`, - ); + return yield* new OpenApiDownloadError({ + message: `Duplicate OpenAPI operationId ${yield* encodeJson(operationId)} claimed by: ${claims.join(", ")}.`, + }); } } -} +}); -export async function downloadOpenApiSpec(): Promise { - const envBaseUrl = process.env.SUPABASE_API_URL; +export const downloadOpenApiSpec = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const envBaseUrl = Option.getOrUndefined(yield* Config.option(Config.String("SUPABASE_API_URL"))); // The pin is consulted only when the environment doesn't override it, so an // explicit SUPABASE_API_URL works even when the pin is absent or malformed. - const pinnedBaseUrl = envBaseUrl === undefined ? await loadPinnedBaseUrl() : undefined; + const pinnedBaseUrl = envBaseUrl === undefined ? yield* loadPinnedBaseUrl : undefined; const baseUrl = resolveOpenApiBaseUrl({ envBaseUrl, pinnedBaseUrl }); - console.log(`Resolved OpenAPI base URL: ${baseUrl}`); + yield* Console.log(`Resolved OpenAPI base URL: ${baseUrl}`); const documents: Array<{ readonly version: OpenApiDocumentVersion; readonly document: OpenApiDocument; }> = []; for (const { version, url } of resolveOpenApiSpecUrls(baseUrl)) { - console.log(`Fetching ${version} OpenAPI document from ${url}`); - const response = await fetch(url); + yield* Console.log(`Fetching ${version} OpenAPI document from ${url}`); + const response = yield* client.get(url); // Hard-fails on a missing document instead of tolerating it: a 404 would // silently drop the whole v2 namespace, and the hourly regeneration sync // would auto-merge that deletion unnoticed. - if (!response.ok) { - throw new Error(`Failed to download OpenAPI spec from ${url}: ${response.status}`); + if (response.status < 200 || response.status > 299) { + return yield* new OpenApiDownloadError({ + message: `Failed to download OpenAPI spec from ${url}: ${response.status}`, + }); } - const document = await response.json(); - assertOpenApiDocument(document); + const body = yield* response.json; + const document = yield* downloadStep(() => { + assertOpenApiDocument(body); + return body; + }); documents.push({ version, document }); } - const mergedDocument = mergeOpenApiDocuments(documents); - applyOpenApiOverrides(mergedDocument, await loadOpenApiOverrides()); - assertMergedOpenApiDocument(mergedDocument); + const mergedDocument = yield* downloadStep(() => mergeOpenApiDocuments(documents)); + const overrides = yield* loadOpenApiOverrides; + yield* downloadStep(() => applyOpenApiOverrides(mergedDocument, overrides)); + yield* assertMergedOpenApiDocument(mergedDocument); - await writeFile(OPENAPI_SPEC_PATH, `${JSON.stringify(mergedDocument, null, 2)}\n`); - await writeOpenApiSource(baseUrl); -} + yield* fs.writeFileString(OPENAPI_SPEC_PATH, `${yield* encodePrettyJson(mergedDocument)}\n`); + yield* writeOpenApiSource(baseUrl); +}); if (import.meta.main) { - await downloadOpenApiSpec(); + await Effect.runPromise( + downloadOpenApiSpec.pipe( + /* Crash with the step's original error so the uncaught report names it, not the wrapper. */ + Effect.catchTags({ + OpenApiDownloadError: (error) => Effect.die(error.cause ?? error), + HttpClientError: (error) => Effect.die(error.cause ?? error), + PlatformError: (error) => Effect.die(error.cause ?? error), + }), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + FetchHttpClient.layer, + Layer.succeed(HttpClient.TracerPropagationEnabled, false), + ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), + ), + ), + ), + ); } diff --git a/packages/api/scripts/download-openapi.unit.test.ts b/packages/api/scripts/download-openapi.unit.test.ts index 6f600f29bb..b9c84e7699 100644 --- a/packages/api/scripts/download-openapi.unit.test.ts +++ b/packages/api/scripts/download-openapi.unit.test.ts @@ -1,3 +1,5 @@ +import { NodeServices } from "@effect/platform-node"; +import { Effect } from "effect"; import { describe, expect, test, vi } from "vitest"; import { @@ -299,7 +301,7 @@ describe("download-openapi", () => { }, }; - expect(() => assertMergedOpenApiDocument(document)).toThrow( + expect(() => Effect.runSync(assertMergedOpenApiDocument(document))).toThrow( 'Duplicate OpenAPI operationId "allV2ProjectsByRefWebhooks" claimed by: GET /v2/projects/{ref}/webhooks/endpoints, GET /v2/projects/{ref}/webhooks/endpoints/{id}.', ); }); @@ -307,7 +309,7 @@ describe("download-openapi", () => { test("throws when an operationId's version prefix disagrees with its path", () => { const document = { paths: { "/v2/x": { get: { operationId: "v1-x" } } } }; - expect(() => assertMergedOpenApiDocument(document)).toThrow( + expect(() => Effect.runSync(assertMergedOpenApiDocument(document))).toThrow( 'OpenAPI operationId "v1-x" for GET /v2/x has version prefix "v1" that does not match the path\'s leading segment "v2".', ); }); @@ -316,7 +318,7 @@ describe("download-openapi", () => { const document = { paths: { "/v1/a": { get: {} } } }; const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - expect(() => assertMergedOpenApiDocument(document)).not.toThrow(); + expect(() => Effect.runSync(assertMergedOpenApiDocument(document))).not.toThrow(); expect(warnSpy).toHaveBeenCalledWith( "OpenAPI operation GET /v1/a has no operationId; generate.ts will skip it.", ); @@ -365,11 +367,14 @@ describe("download-openapi", () => { const test_ = (path: string, value: unknown = []) => ({ op: "test", path, value }); const replace = (path: string, value: unknown = ["x"]) => ({ op: "replace", path, value }); - test("accepts the committed overrides file", async () => { - const overrides = await loadOpenApiOverrides(); + test("accepts the committed overrides file", () => + Effect.runPromise( + Effect.gen(function* () { + const overrides = yield* loadOpenApiOverrides; - expect(overrides.length).toBeGreaterThan(0); - }); + expect(overrides.length).toBeGreaterThan(0); + }).pipe(Effect.provide(NodeServices.layer)), + )); test("rejects a replace that is not directly preceded by a test on the same path", () => { expect(() => diff --git a/packages/api/scripts/generate.ts b/packages/api/scripts/generate.ts index db7c7b4d7c..80c093368e 100644 --- a/packages/api/scripts/generate.ts +++ b/packages/api/scripts/generate.ts @@ -1,8 +1,8 @@ #!/usr/bin/env bun -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import path from "node:path"; import { fileURLToPath } from "node:url"; +import { NodeServices } from "@effect/platform-node"; +import { Console, Effect, FileSystem, Path, Schema } from "effect"; import * as Arr from "effect/Array"; import * as JsonSchema from "effect/JsonSchema"; import * as SchemaRepresentation from "effect/SchemaRepresentation"; @@ -137,17 +137,35 @@ const httpMethods: Record = { head: "HEAD", }; -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDir, "../../.."); -const sourceSpecPath = path.join(repoRoot, "packages/api/src/generated/openapi.json"); -const generatedDir = path.join(repoRoot, "packages/api/src/generated"); +const sourceSpecPath = fileURLToPath(new URL("../src/generated/openapi.json", import.meta.url)); +const generatedDir = fileURLToPath(new URL("../src/generated", import.meta.url)); + +class GenerateError extends Schema.TaggedError()("GenerateError", { + message: Schema.String, + cause: Schema.Defect(), +}) {} + +const generationStep = (step: () => A) => + Effect.try({ + try: step, + catch: (cause) => + new GenerateError({ message: cause instanceof Error ? cause.message : String(cause), cause }), + }); function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -export function loadSpec(): OpenApiDocument { - const parsed = JSON.parse(readFileSync(sourceSpecPath, "utf8")); +export const loadSpec = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const source = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + yield* fs.readFile(sourceSpecPath), + ); + return yield* generationStep(() => parseSpec(source)); +}); + +function parseSpec(source: string): OpenApiDocument { + const parsed = JSON.parse(source); if (!isRecord(parsed) || !isRecord(parsed.paths)) { throw new Error(`Invalid OpenAPI document at ${sourceSpecPath}`); } @@ -1182,21 +1200,49 @@ ${rows.join("\n")} `; } -function main() { - const document = loadSpec(); - const operations = extractOperations(document); +const encodePrettyJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 })); - rmSync(generatedDir, { recursive: true, force: true }); - mkdirSync(generatedDir, { recursive: true }); +export const renderOpenApiJson = (document: OpenApiDocument) => + Effect.map(encodePrettyJson(document), (json) => `${json}\n`); - writeFileSync(path.join(generatedDir, "contracts.ts"), renderContracts(document, operations)); - writeFileSync(path.join(generatedDir, "effect-client.ts"), renderEffectClient(operations)); - writeFileSync(path.join(generatedDir, "permissions.ts"), renderPermissions(document)); - writeFileSync(path.join(generatedDir, "openapi.json"), `${JSON.stringify(document, null, 2)}\n`); +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const document = yield* loadSpec; + const operations = yield* generationStep(() => extractOperations(document)); - console.log(`Generated ${operations.length} API operations in ${generatedDir}`); -} + yield* fs.remove(generatedDir, { recursive: true, force: true }); + yield* fs.makeDirectory(generatedDir, { recursive: true }); + + yield* fs.writeFileString( + path.join(generatedDir, "contracts.ts"), + yield* generationStep(() => renderContracts(document, operations)), + ); + yield* fs.writeFileString( + path.join(generatedDir, "effect-client.ts"), + yield* generationStep(() => renderEffectClient(operations)), + ); + yield* fs.writeFileString( + path.join(generatedDir, "permissions.ts"), + yield* generationStep(() => renderPermissions(document)), + ); + yield* fs.writeFileString( + path.join(generatedDir, "openapi.json"), + yield* renderOpenApiJson(document), + ); + + yield* Console.log(`Generated ${operations.length} API operations in ${generatedDir}`); +}); if (import.meta.main) { - main(); + await Effect.runPromise( + main.pipe( + /* Crash with the step's original error so the uncaught report names it, not the wrapper. */ + Effect.catchTags({ + GenerateError: (error) => Effect.die(error.cause), + PlatformError: (error) => Effect.die(error.cause ?? error), + }), + Effect.provide(NodeServices.layer), + ), + ); } diff --git a/packages/api/scripts/generated-output-sync.unit.test.ts b/packages/api/scripts/generated-output-sync.unit.test.ts index c9f5f3e802..ce6503e324 100644 --- a/packages/api/scripts/generated-output-sync.unit.test.ts +++ b/packages/api/scripts/generated-output-sync.unit.test.ts @@ -1,7 +1,8 @@ -import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import path from "node:path"; import { fileURLToPath } from "node:url"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path } from "effect"; +import * as ChildProcess from "effect/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; import { describe, expect, test } from "vitest"; import { @@ -9,6 +10,7 @@ import { loadSpec, renderContracts, renderEffectClient, + renderOpenApiJson, renderPermissions, } from "./generate.ts"; @@ -18,104 +20,122 @@ import { // bodies, response types, and the executor switch, which the operation-level // bijection test in src/generated-contract-sync doesn't. -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const packageDir = path.join(scriptDir, ".."); -const repoDir = path.join(packageDir, "..", ".."); -const generatedDir = path.join(packageDir, "src", "generated"); +const packageDir = fileURLToPath(new URL("..", import.meta.url)); +const repoDir = fileURLToPath(new URL("../../..", import.meta.url)); +const generatedDir = fileURLToPath(new URL("../src/generated", import.meta.url)); // oxfmt is a repo-root devDependency configured by the root .oxfmtrc.json, // which is also how the pipeline's root fmt:fix target runs it. -const oxfmtBin = path.join(repoDir, "node_modules", ".bin", "oxfmt"); +const oxfmtBin = fileURLToPath(new URL("../../../node_modules/.bin/oxfmt", import.meta.url)); +const utf8 = new TextDecoder("utf-8", { ignoreBOM: true }); -function formatWithOxfmt(source: string, fileName: string): string { - // Runs oxfmt in file mode, not via stdin/stdout: Bun on Linux truncates a - // child's piped stdout at ~219 KB, and these renders are 600+ KB. The temp - // dir lives inside the package (same oxfmt config) but outside node_modules, - // which oxfmt skips by default. - const tempDir = mkdtempSync(path.join(packageDir, ".generated-output-sync-")); - try { +const formatWithOxfmt = (source: string, fileName: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner; + // Runs oxfmt in file mode, not via stdin/stdout: Bun on Linux truncates a + // child's piped stdout at ~219 KB, and these renders are 600+ KB. The temp + // dir lives inside the package (same oxfmt config) but outside node_modules, + // which oxfmt skips by default. + const tempDir = yield* fs.makeTempDirectoryScoped({ + directory: packageDir, + prefix: ".generated-output-sync-", + }); const tempFile = path.join(tempDir, fileName); - writeFileSync(tempFile, source); - execFileSync(oxfmtBin, [tempFile], { cwd: repoDir }); - return readFileSync(tempFile, "utf8"); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -} + yield* fs.writeFileString(tempFile, source); + const exitCode = yield* spawner.exitCode( + ChildProcess.make(oxfmtBin, [tempFile], { + cwd: repoDir, + stdin: "ignore", + stdout: "ignore", + stderr: "inherit", + }), + ); + if (exitCode !== 0) { + return yield* Effect.die(`oxfmt exited with code ${exitCode} for ${tempFile}`); + } + return utf8.decode(yield* fs.readFile(tempFile)); + }).pipe(Effect.scoped); -function committedFile(fileName: string): string { - return readFileSync(path.join(generatedDir, fileName), "utf8"); -} +const committedFile = (fileName: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return utf8.decode(yield* fs.readFile(path.join(generatedDir, fileName))); + }); -function expectSameSource(rendered: string, fileName: string): void { - const committed = committedFile(fileName); - if (rendered === committed) { - return; - } - const renderedLines = rendered.split("\n"); - const committedLines = committed.split("\n"); - const limit = Math.min(renderedLines.length, committedLines.length); - let line = 0; - while (line < limit && renderedLines[line] === committedLines[line]) { - line += 1; - } - expect.fail( - `src/generated/${fileName} is not what the generator renders from the committed snapshot ` + - `(first difference at line ${line + 1}):\n` + - ` committed: ${JSON.stringify(committedLines[line] ?? "")}\n` + - ` rendered: ${JSON.stringify(renderedLines[line] ?? "")}\n` + - `Hand edits to src/generated are not allowed — run \`pnpm generate\` instead.`, - ); -} +const expectSameSource = (rendered: string, fileName: string) => + Effect.map(committedFile(fileName), (committed) => { + if (rendered === committed) { + return; + } + const renderedLines = rendered.split("\n"); + const committedLines = committed.split("\n"); + const limit = Math.min(renderedLines.length, committedLines.length); + let line = 0; + while (line < limit && renderedLines[line] === committedLines[line]) { + line += 1; + } + expect.fail( + `src/generated/${fileName} is not what the generator renders from the committed snapshot ` + + `(first difference at line ${line + 1}):\n` + + ` committed: ${JSON.stringify(committedLines[line] ?? "")}\n` + + ` rendered: ${JSON.stringify(renderedLines[line] ?? "")}\n` + + `Hand edits to src/generated are not allowed — run \`pnpm generate\` instead.`, + ); + }); // Rendering contracts.ts runs the real schema codegen for every operation, // which takes well over vitest's default 5s budget. const RENDER_TIMEOUT_MS = 120_000; +const document = await Effect.runPromise(loadSpec.pipe(Effect.provide(NodeServices.layer))); + describe("generated output sync", () => { - const document = loadSpec(); const operations = extractOperations(document); test( "contracts.ts is byte-identical to the generator's render of the committed snapshot", { timeout: RENDER_TIMEOUT_MS }, - () => { - expectSameSource( - formatWithOxfmt(renderContracts(document, operations), "contracts.ts"), - "contracts.ts", - ); - }, + () => + formatWithOxfmt(renderContracts(document, operations), "contracts.ts").pipe( + Effect.flatMap((formatted) => expectSameSource(formatted, "contracts.ts")), + Effect.provide(NodeServices.layer), + Effect.runPromise, + ), ); test( "effect-client.ts is byte-identical to the generator's render of the committed snapshot", { timeout: RENDER_TIMEOUT_MS }, - () => { - expectSameSource( - formatWithOxfmt(renderEffectClient(operations), "effect-client.ts"), - "effect-client.ts", - ); - }, + () => + formatWithOxfmt(renderEffectClient(operations), "effect-client.ts").pipe( + Effect.flatMap((formatted) => expectSameSource(formatted, "effect-client.ts")), + Effect.provide(NodeServices.layer), + Effect.runPromise, + ), ); test( "permissions.ts is byte-identical to the generator's render of the committed snapshot", { timeout: RENDER_TIMEOUT_MS }, - () => { - expectSameSource( - formatWithOxfmt(renderPermissions(document), "permissions.ts"), - "permissions.ts", - ); - }, + () => + formatWithOxfmt(renderPermissions(document), "permissions.ts").pipe( + Effect.flatMap((formatted) => expectSameSource(formatted, "permissions.ts")), + Effect.provide(NodeServices.layer), + Effect.runPromise, + ), ); test( "openapi.json is byte-identical to the generator's normalized rewrite of itself", { timeout: RENDER_TIMEOUT_MS }, - () => { - expectSameSource( - formatWithOxfmt(`${JSON.stringify(document, null, 2)}\n`, "openapi.json"), - "openapi.json", - ); - }, + () => + renderOpenApiJson(document).pipe( + Effect.flatMap((json) => formatWithOxfmt(json, "openapi.json")), + Effect.flatMap((formatted) => expectSameSource(formatted, "openapi.json")), + Effect.provide(NodeServices.layer), + Effect.runPromise, + ), ); });